1 Commits

Author SHA1 Message Date
Sashanoraa
c2226071d2 Dynamically determine frame buffer dimensions and format
This commit also refactors a majority of the device specific frame
buffer code into common code into generic_framebuffer.
2025-07-30 22:46:26 -04:00
200 changed files with 2486 additions and 19103 deletions

View File

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

View File

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

10
.gitattributes vendored
View File

@@ -1,10 +0,0 @@
# Files that are distributed onto the Rayhunter device always have to have
# Unix-style line endings, even if the installer is built on Windows with
# autocrlf enabled.
# Using CRLF for the init scripts will make them fail to execute on TP-Link.
# See https://github.com/EFForg/rayhunter/issues/489
dist/config.toml.in eol=lf
dist/scripts/misc-daemon eol=lf
dist/scripts/rayhunter_daemon eol=lf
scripts/*.sh eol=lf

View File

@@ -2,12 +2,6 @@ name: Bug Report
description: File a bug report.
labels: ["bug"]
body:
- type: checkboxes
attributes:
label: Prerequisites
options:
- label: I have read [CONTRIBUTING.md](https://github.com/EFForg/rayhunter/blob/main/CONTRIBUTING.md)
required: true
- type: textarea
attributes:
label: Bug Report Details

View File

@@ -1,10 +1,8 @@
blank_issues_enabled: false
blank_issues_enabled: true
contact_links:
- name: Frequently Asked Questions
url: https://efforg.github.io/rayhunter/faq.html
- name: Questions and community
url: https://efforg.github.io/rayhunter/support-feedback-community.html
about: If you're having trouble using Rayhunter and aren't sure you've found a bug or request for a new feature, please first try asking for help on GitHub discussions or Mattermost
- name: Rayhunter Mattermost
url: https://opensource.eff.org/signup_user_complete/?id=6iqur37ucfrctfswrs14iscobw&md=link&sbr=su
about: If you're having trouble using Rayhunter and aren't sure you've found a bug or request for a new feature, please first try asking for help here. There is a much larger community there of people familiar with the project who will be able to more quickly answer your questions.
- name: Rayhunter Security Policy
url: https://github.com/EFForg/rayhunter/security/advisories/new
about: Please report security vulnerabilities here.

View File

@@ -2,12 +2,6 @@ name: Feature Request
description: Suggest a new feature or improvement to Rayhunter
labels: ["enhancement"]
body:
- type: checkboxes
attributes:
label: Prerequisites
options:
- label: I have read [CONTRIBUTING.md](https://github.com/EFForg/rayhunter/blob/main/CONTRIBUTING.md)
required: true
- type: textarea
id: problem
attributes:

View File

@@ -1,53 +0,0 @@
name: Installer Issue
description: File an bug related to an installer issue.
labels: ["bug", "installer"]
body:
- type: checkboxes
attributes:
label: Prerequisites
options:
- label: I have read [CONTRIBUTING.md](https://github.com/EFForg/rayhunter/blob/main/CONTRIBUTING.md)
required: true
- type: input
attributes:
label: Rayhunter Version
placeholder: 'v0.5.0'
validations:
required: true
- type: dropdown
attributes:
label: Device
description: |
What device are you trying to install Rayhunter on?
options:
- Orbic RC400L
- Tplink M7350
- Tplink M7310
- Tmobile TMOHS1
- Wingtech CT2MHS0
- Pinephone
- Other / I'm not sure
validations:
required: true
- type: dropdown
attributes:
label: Installer OS
description: What operating system are running the installer from
multiple: false
options:
- Linux
- macOS
- Windows
validations:
required: true
- type: textarea
attributes:
label: Describe the Issue
description: |
Please describe the issue you're having installing Rayhunter.
Include the logs outputed by the installer program. If the installer
is crashing, please try running the installer with `RUST_BACKTRACE=1`
environment variable set so we can see exactly where the installer is
crashing.
validations:
required: true

View File

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

View File

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

View File

@@ -20,89 +20,66 @@ jobs:
permissions:
contents: read
outputs:
code_changed: ${{ steps.files_changed.outputs.code_count != '0' }}
daemon_changed: ${{ steps.files_changed.outputs.daemon_count != '0' }}
daemon_needed: ${{ steps.files_changed.outputs.daemon_count != '0' || steps.files_changed.outputs.installer_build != '0' }}
web_changed: ${{ steps.files_changed.outputs.web_count != '0' }}
docs_changed: ${{ steps.files_changed.outputs.docs_count != '0' || steps.files_changed.outputs.daemon_count != '0' }}
installer_changed: ${{ steps.files_changed.outputs.installer_count != '0' }}
installer_gui_changed: ${{ steps.files_changed.outputs.installer_gui_count != '0' }}
rootshell_needed: ${{ steps.files_changed.outputs.rootshell_count != '0' || steps.files_changed.outputs.installer_build != '0' }}
code_changed: ${{ steps.files_changed.outputs.code_count }}
daemon_changed: ${{ steps.files_changed.outputs.daemon_count }}
web_changed: ${{ steps.files_changed.outputs.web_count }}
docs_changed: ${{ steps.files_changed.outputs.docs_count }}
installer_changed: ${{ steps.files_changed.outputs.installer_count }}
rootshell_changed: ${{ steps.files_changed.outputs.rootshell_count }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: detect file changes
id: files_changed
run: |
lcommit=${{ github.event.pull_request.base.sha || 'origin/main' }}
# If we are on main, if workflow/cargo config files changed, or if
# the latest commit message contains "#build-all", run everything.
# Use #build-all in a commit message to force a full build on a PR
# branch (useful for testing release builds without merging to main).
if [ ${GITHUB_REF} = 'refs/heads/main' ] || git diff --name-only $lcommit..HEAD | grep -qe ^.github/workflows/ -e ^.cargo || git log -1 --format='%s %b' | grep -qF '#build-all'
# If we are on main, or if these workflow files are being changed, run everything
if [ ${{ github.ref }} = 'refs/heads/main' ] || git diff --name-only $lcommit..HEAD | grep -qe ^.github/workflows/ -e ^.cargo
then
echo "building everything"
echo code_count=forced >> "$GITHUB_OUTPUT"
echo daemon_count=forced >> "$GITHUB_OUTPUT"
echo web_count=forced >> "$GITHUB_OUTPUT"
echo docs_count=forced >> "$GITHUB_OUTPUT"
echo installer_build=forced >> "$GITHUB_OUTPUT"
echo installer_count=forced >> "$GITHUB_OUTPUT"
echo installer_gui_count=forced >> "$GITHUB_OUTPUT"
echo rootshell_count=forced >> "$GITHUB_OUTPUT"
else
echo "code_count=$(git diff --name-only $lcommit...HEAD | grep -e ^daemon -e ^installer -e ^check -e ^lib -e ^rootshell -e ^telcom-parser | wc -l)" >> "$GITHUB_OUTPUT"
echo "daemon_count=$(git diff --name-only $lcommit...HEAD | grep -e ^daemon -e ^lib -e ^telcom-parser | wc -l)" >> "$GITHUB_OUTPUT"
echo "web_count=$(git diff --name-only $lcommit...HEAD | grep -e ^daemon/web | wc -l)" >> "$GITHUB_OUTPUT"
echo "docs_count=$(git diff --name-only $lcommit...HEAD | grep -e ^book.toml -e ^doc | wc -l)" >> "$GITHUB_OUTPUT"
echo "installer_count=$(git diff --name-only $lcommit...HEAD | grep -e ^installer | wc -l)" >> "$GITHUB_OUTPUT"
echo "rootshell_count=$(git diff --name-only $lcommit...HEAD | grep -e ^rootshell | wc -l)" >> "$GITHUB_OUTPUT"
installer_count=$(git diff --name-only $lcommit...HEAD | grep -e ^installer/ | wc -l)
installer_gui_count=$(git diff --name-only $lcommit...HEAD | grep -e ^installer-gui | wc -l)
if [ $installer_count != "0" ] || [ $installer_gui_count != "0" ]; then
echo "installer_build=1" >> "$GITHUB_OUTPUT"
else
echo "installer_build=0" >> "$GITHUB_OUTPUT"
fi
echo "installer_count=$installer_count" >> "$GITHUB_OUTPUT"
echo "installer_gui_count=$installer_gui_count" >> "$GITHUB_OUTPUT"
fi
mdbook_test:
name: Test mdBook Documentation builds
needs: files_changed
if: needs.files_changed.outputs.docs_changed == 'true'
if: needs.files_changed.outputs.docs_changed != '0'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: Swatinem/rust-cache@v2
- name: Install mdBook
run: |
cargo install mdbook --no-default-features --features search --vers "^0.4" --locked
- name: Test mdBook
run: mdbook test
mdbook_build:
name: Build mdBook for Github Pages
mdbook_publish:
name: Publish mdBook to Github Pages
needs: mdbook_test
if: ${{ github.ref == 'refs/heads/main' }}
permissions:
pages: write
contents: write
id-token: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: Swatinem/rust-cache@v2
- name: Install mdBook
run: |
cargo install mdbook --no-default-features --features search --vers "^0.4" --locked
@@ -110,25 +87,23 @@ jobs:
- name: Build mdBook
run: mdbook build
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Upload artifact
uses: actions/upload-artifact@v4
uses: actions/upload-pages-artifact@v3
with:
name: book
path: book
- name: Deploy to Github Pages
uses: actions/deploy-pages@v4
check_and_test:
needs: files_changed
if: needs.files_changed.outputs.code_changed == 'true'
if: needs.files_changed.outputs.code_changed != '0'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- name: Check formatting
run: cargo fmt --all --check
@@ -146,37 +121,9 @@ jobs:
run: |
NO_FIRMWARE_BIN=true cargo clippy --verbose
installer_gui_check:
# we test the GUI installer separately to:
# 1) mimic the default behavior of cargo commands for rayhunter devs where
# installer-gui isn't one of the default workspace packages
# 2) avoid slowing down development on changes unrelated to the GUI installer
test_web_frontend:
needs: files_changed
if: needs.files_changed.outputs.installer_gui_changed == 'true'
# we run this on macos simply because no additional OS packages need to be
# installed
runs-on: macos-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
# we don't need to run cargo fmt here because both cargo fmt and cargo
# fmt --all runs on all workspace packages so this is handled by
# check_and_test above
- name: Check
run: NO_FIRMWARE_BIN=true cargo check --package installer-gui --verbose
- name: Run clippy
run: NO_FIRMWARE_BIN=true cargo clippy --package installer-gui --verbose
test_daemon_frontend:
needs: files_changed
if: needs.files_changed.outputs.web_changed == 'true'
if: needs.files_changed.outputs.web_changed != '0'
runs-on: ubuntu-latest
permissions:
contents: read
@@ -185,40 +132,19 @@ jobs:
working-directory: daemon/web
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- run: npm install
- run: npm run lint
- run: npm run check
- run: npm run test
test_installer_frontend:
needs: files_changed
if: needs.files_changed.outputs.installer_gui_changed == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: installer-gui
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- run: npm install
- run: npm run lint
- run: npm run check
windows_installer_check_and_test:
needs: files_changed
if: needs.files_changed.outputs.installer_changed == 'true'
if: needs.files_changed.outputs.installer_changed != '0'
runs-on: windows-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: Swatinem/rust-cache@v2
- name: cargo check
shell: bash
@@ -232,7 +158,7 @@ jobs:
NO_FIRMWARE_BIN=true cargo test --verbose --no-default-features
build_rayhunter_check:
if: needs.files_changed.outputs.daemon_changed == 'true'
if: needs.files_changed.outputs.daemon_changed != '0'
needs:
- check_and_test
- files_changed
@@ -255,7 +181,7 @@ jobs:
os: macos-latest
target: aarch64-apple-darwin
- name: macos-intel
os: macos-latest
os: macos-13
target: x86_64-apple-darwin
- name: windows-x86_64
os: windows-latest
@@ -263,8 +189,6 @@ jobs:
runs-on: ${{ matrix.platform.os }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform.target }}
@@ -278,7 +202,7 @@ jobs:
if-no-files-found: error
build_rootshell:
if: needs.files_changed.outputs.rootshell_needed == 'true'
if: needs.files_changed.outputs.rootshell_changed != '0'
needs:
- check_and_test
- files_changed
@@ -287,14 +211,12 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
with:
targets: armv7-unknown-linux-musleabihf
- uses: Swatinem/rust-cache@v2
- name: Build rootshell (armv7)
run: cargo build -p rootshell --bin rootshell --target armv7-unknown-linux-musleabihf --profile=firmware
run: cargo build --bin rootshell --target armv7-unknown-linux-musleabihf --profile=firmware
- uses: actions/upload-artifact@v4
with:
name: rootshell
@@ -302,7 +224,7 @@ jobs:
if-no-files-found: error
build_rayhunter:
if: needs.files_changed.outputs.daemon_needed == 'true'
if: needs.files_changed.outputs.daemon_changed != '0'
needs:
- check_and_test
- files_changed
@@ -312,27 +234,25 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
persist-credentials: false
- name: Build frontend
targets: armv7-unknown-linux-musleabihf
- uses: Swatinem/rust-cache@v2
- name: Build rayhunter-daemon (armv7)
run: |
pushd daemon/web
npm install
npm run build
popd
- name: Build rayhunter-daemon (armv7)
# Cross-compile inside messense/rust-musl-cross, which bundles an
# arm-linux-musleabihf cross gcc that aws-lc-sys needs.
run: |
mkdir -p "$HOME/.cargo-musl-cross"
docker run --rm \
--user "$(id -u):$(id -g)" \
-v "$PWD":/work \
-v "$HOME/.cargo-musl-cross":/cargo-home \
-e CARGO_HOME=/cargo-home \
-w /work \
messense/rust-musl-cross:armv7-musleabihf \
cargo build-daemon-firmware
# Run with -p so that cargo will select the minimum feature set for this package.
#
# Otherwise, it will consider the union of all requested features
# from all packages in the workspace. For example, if installer
# requires tokio with "full" feature, it will be included no matter
# what the feature selection in rayhunter-daemon is.
#
# https://github.com/rust-lang/cargo/issues/4463
cargo build -p rayhunter-daemon --bin rayhunter-daemon --target armv7-unknown-linux-musleabihf --profile=firmware
- uses: actions/upload-artifact@v4
with:
name: rayhunter-daemon
@@ -340,7 +260,7 @@ jobs:
if-no-files-found: error
build_rust_installer:
if: needs.files_changed.outputs.installer_changed == 'true'
if: needs.files_changed.outputs.installer_changed != '0'
permissions:
contents: read
packages: write
@@ -365,7 +285,7 @@ jobs:
os: macos-latest
target: aarch64-apple-darwin
- name: macos-intel
os: macos-latest
os: macos-13
target: x86_64-apple-darwin
- name: windows-x86_64
os: windows-latest
@@ -373,8 +293,6 @@ jobs:
runs-on: ${{ matrix.platform.os }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/download-artifact@v4
- uses: dtolnay/rust-toolchain@stable
with:
@@ -387,145 +305,6 @@ jobs:
path: target/${{ matrix.platform.target }}/release/installer${{ matrix.platform.os == 'windows-latest' && '.exe' || '' }}
if-no-files-found: error
build_installer_gui_linux:
if: needs.files_changed.outputs.installer_gui_changed == 'true'
permissions:
contents: read
packages: write
needs:
- build_rayhunter
- build_rootshell
- files_changed
- installer_gui_check
- test_installer_frontend
strategy:
matrix:
platform:
# we want to use the oldest supported version of ubuntu here to
# maximize compatibility with older versions of glibc
- name: linux-x64
os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
- name: linux-aarch64
os: ubuntu-22.04-arm
target: aarch64-unknown-linux-gnu
runs-on: ${{ matrix.platform.os }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/download-artifact@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform.target }}
- uses: Swatinem/rust-cache@v2
- name: Install tauri dependencies
run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev xdg-utils
- name: Build GUI installer
shell: bash
run: |
cd installer-gui
npm install
npm run tauri build -- --target ${{ matrix.platform.target }}
- uses: actions/upload-artifact@v4
with:
name: gui-installer-${{ matrix.platform.name }}-appimage
path: target/${{ matrix.platform.target }}/release/bundle/appimage/*.AppImage
if-no-files-found: error
- uses: actions/upload-artifact@v4
with:
name: gui-installer-${{ matrix.platform.name }}-deb
path: target/${{ matrix.platform.target }}/release/bundle/deb/*.deb
if-no-files-found: error
- uses: actions/upload-artifact@v4
with:
name: gui-installer-${{ matrix.platform.name }}-rpm
path: target/${{ matrix.platform.target }}/release/bundle/rpm/*.rpm
if-no-files-found: error
build_installer_gui_macos:
if: needs.files_changed.outputs.installer_gui_changed == 'true'
permissions:
contents: read
packages: write
needs:
- build_rayhunter
- build_rootshell
- files_changed
- installer_gui_check
- test_installer_frontend
strategy:
matrix:
platform:
- name: macos-arm
target: aarch64-apple-darwin
- name: macos-intel
target: x86_64-apple-darwin
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/download-artifact@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform.target }}
- uses: Swatinem/rust-cache@v2
- name: Build GUI installer
shell: bash
run: |
cd installer-gui
npm install
npm run tauri build -- --target ${{ matrix.platform.target }}
cd ..
mv "target/${{ matrix.platform.target }}/release/bundle/macos/"*.app .
zip -r "rayhunter-installer-${{ matrix.platform.name }}.app.zip" ./*.app
- uses: actions/upload-artifact@v4
with:
name: gui-installer-${{ matrix.platform.name }}-app
path: ./*.app.zip
if-no-files-found: error
build_installer_gui_windows:
if: needs.files_changed.outputs.installer_gui_changed == 'true'
permissions:
contents: read
packages: write
needs:
- build_rayhunter
- build_rootshell
- files_changed
- installer_gui_check
- test_installer_frontend
env:
TARGET: x86_64-pc-windows-msvc
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/download-artifact@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ env.TARGET }}
- uses: Swatinem/rust-cache@v2
- name: Build GUI installer
shell: bash
run: |
cd installer-gui
npm install
npm run tauri build -- --target ${{ env.TARGET }}
- uses: actions/upload-artifact@v4
with:
name: gui-installer-msi
path: target/${{ env.TARGET }}/release/bundle/msi/*.msi
if-no-files-found: error
- uses: actions/upload-artifact@v4
with:
name: gui-installer-exe
path: target/${{ env.TARGET }}/release/bundle/nsis/*.exe
if-no-files-found: error
build_release_zip:
permissions:
contents: read
@@ -547,8 +326,6 @@ jobs:
- windows-x86_64
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/download-artifact@v4
- name: Fix executable permissions on binaries
run: chmod +x installer-*/installer rayhunter-check-*/rayhunter-check rayhunter-daemon/rayhunter-daemon
@@ -558,15 +335,10 @@ jobs:
- name: Setup versioned release directory
run: |
platform="${{ matrix.platform }}"
dest="rayhunter-v${VERSION}-${{ matrix.platform }}"
dest="rayhunter-v${{ env.VERSION }}-${{ matrix.platform }}"
mkdir "$dest"
# Handle installer with proper extension for Windows
if [ "$platform" = "windows-x86_64" ]; then
mv installer-$platform/installer.exe "$dest"/installer.exe
else
mv installer-$platform/installer "$dest"/installer
fi
cp -r rayhunter-check-* rayhunter-daemon dist/scripts "$dest"/
mv installer-$platform/installer* "$dest"/installer
cp -r rayhunter-daemon rootshell/rootshell dist/* installer/install.ps1 "$dest"/
zip -r "$dest.zip" "$dest"
sha256sum "$dest.zip" > "$dest.zip.sha256"
@@ -578,57 +350,3 @@ jobs:
rayhunter-v${{ env.VERSION }}-${{ matrix.platform }}.zip
rayhunter-v${{ env.VERSION }}-${{ matrix.platform }}.zip.sha256
if-no-files-found: error
openapi_build:
if: needs.files_changed.outputs.docs_changed == 'true'
needs:
- files_changed
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
with:
targets: armv7-unknown-linux-musleabihf
- uses: Swatinem/rust-cache@v2
- name: Build rayhunter-daemon openapi docs
run: |
mkdir -p daemon/web/build
touch daemon/web/build/{favicon.png,index.html.gz,rayhunter_orca_only.png,rayhunter_text.png}
cargo run --bin gen_api --features apidocs -- ./rayhunter-openapi.json
- name: Make swagger folder
run: |
mkdir api-docs
mv doc/swagger-ui.html api-docs/index.html
mv rayhunter-openapi.json api-docs/
- uses: actions/upload-artifact@v4
with:
name: api-docs
path: api-docs
github_pages_publish:
name: Upload new documentation to Github Pages
if: ${{ github.ref == 'refs/heads/main' }}
permissions:
pages: write
contents: write
id-token: write
needs:
- mdbook_build
- openapi_build
runs-on: ubuntu-latest
steps:
- name: Setup Pages
uses: actions/configure-pages@v4
- uses: actions/download-artifact@v4
- name: Organize pages into directory
run: cp -a api-docs book/
- name: Upload pages
uses: actions/upload-pages-artifact@v3
with:
path: book
- name: Deploy Github Pages
uses: actions/deploy-pages@v4

View File

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

View File

@@ -1,85 +0,0 @@
# How to contribute to Rayhunter
## Filing issues and starting discussions
Our issue tracker is [on GitHub](https://github.com/EFForg/rayhunter/issues).
- If your rayhunter has found an IMSI-catcher, we strongly encourage you to
[send us that information
privately.](https://efforg.github.io/rayhunter/faq.html#help-rayhunters-line-is-redorangeyellowdotteddashed-what-should-i-do) via Signal.
- Issues should be actionable. If you don't have a
specific feature request or bug report, consider [creating a
discussion](https://github.com/EFForg/rayhunter/discussions) or [joining our Mattermost](https://efforg.github.io/rayhunter/support-feedback-community.html) instead.
Example of a good bug report:
- "Installer broken on TP-Link M7350 v3.0"
- "Display does not update to green after finding"
- "The documentation is wrong" (though we encourage you to file a pull request directly)
Example of a good feature request:
- "Use LED on device XYZ for showing recording status"
Example of something that belongs into discussion:
- "In region XYZ, do I need an activated SIM?"
- "Where to buy this device in region XYZ?"
- "Can this device be supported?" While this is a valid feature
request, we just get this request too often, and without some exploratory
work done upfront it's often unclear initially if that device can be
supported at all.
- The issue templates are mostly there to give you a clue what kind of
information is needed from you, and whether your request belongs into the issue
tracker. Fill them out to be on the safe side, but they are not mandatory.
## Contributing patches
To edit documentation or fix a bug, make a pull request. If you're about to
write a substantial amount of code or implement a new feature, we strongly
encourage you to talk to us before implementing it or check if any issues have
been opened for it already. Otherwise there is a chance we will reject your
contribution after you have spent time on it.
On the other hand, for small documentation fixes you can file a PR without
filing an issue.
Otherwise:
- Refer to [installing from
source](https://efforg.github.io/rayhunter/installing-from-source.html) for
how to build Rayhunter from the git repository.
- Ensure that `cargo fmt` and `cargo clippy` have been run.
- If you add new features, please do your best to both write tests for and also
manually test them. Our test coverage isn't great, but as new features are
added we are trying to prevent it from becoming worse.
- Please keep your contributions to less than approximately 400 lines of code not counting tests, (going slightly over is fine, we aren't dogmatic about it.) This is because we are not able to give quality code review to contributions larger than that and risk introducing bugs into the system. [There was a study showing 400 LOC is the max most humans can handle.](https://smartbear.com/learn/code-review/best-practices-for-peer-code-review/)
If you have any questions [feel free to open a discussion or chat with us on Mattermost.](https://efforg.github.io/rayhunter/support-feedback-community.html)
### Policy regarding AI-generated contributions:
- Please refrain from submissions that you haven't thoroughly understood, reviewed, and tested.
- Please disclose if your contribution was AI-generated
- Descriptions and comments should be made by you
You can read our [full policy](https://www.eff.org/about/opportunities/volunteer/coding-with-eff) and some writing on [our motivations](https://www.eff.org/deeplinks/2026/02/effs-policy-llm-assisted-contributions-our-open-source-projects).
## Making releases
This one is for maintainers of Rayhunter.
1. Make a PR changing the versions in `Cargo.toml` and other files.
This could be automated better but right now it's manual. You can do this easily with sed:
`sed -i "" -E 's/x.x.x/y.y.y/g' */Cargo.toml`
2. Merge PR and make a tag.
3. [Run release workflow.](https://github.com/EFForg/rayhunter/actions/workflows/release.yml)
4. Write changelog, edit it into the release, announce on mattermost.

4119
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,19 +1,7 @@
# Rayhunter
![Tests](https://github.com/EFForg/rayhunter/actions/workflows/main.yml/badge.svg)
![Rayhunter Logo - An Orca taking a bite out of a cellular signal bar](https://www.eff.org/files/styles/media_browser_preview/public/banner_library/rayhunter-banner.png)
Rayhunter is a project for detecting IMSI catchers, also known as cell-site simulators or stingrays. It was first designed to run on a cheap mobile hotspot called the Orbic RC400L, but thanks to community efforts, it can [support some other devices as well](https://efforg.github.io/rayhunter/supported-devices.html).
It's also designed to be as easy to install and use as possible, regardless of your level of technical skills, and to minimize false positives.
# Rayhunter
→ Check out the [installation guide](https://efforg.github.io/rayhunter/installation.html) to get started.
![Tests](https://github.com/EFForg/rayhunter/actions/workflows/main.yml/badge.svg)
→ To learn more about the aim of the project, and about IMSI catchers in general, please check out our [introductory blog post](https://www.eff.org/deeplinks/2025/03/meet-rayhunter-new-open-source-tool-eff-detect-cellular-spying).
→ For discussion, help, or to join the mattermost channel and get involved with the project and community check out the [many ways listed here](https://efforg.github.io/rayhunter/support-feedback-community.html)!
→ To learn more about the project in general check out the [Rayhunter Book](https://efforg.github.io/rayhunter/).
**LEGAL DISCLAIMER:** Use this program at your own risk. We believe running this program does not currently violate any laws or regulations in the United States. However, we are not responsible for civil or criminal liability resulting from the use of this software. If you are located outside of the US please consult with an attorney in your country to help you assess the legal risks of running this program.
*Good Hunting!*
Rayhunter is an IMSI Catcher Catcher for the Orbic mobile hotspot. To learn more, check out the [Rayhunter Book](https://efforg.github.io/rayhunter/).

View File

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

View File

@@ -1,6 +1,6 @@
[package]
name = "rayhunter-check"
version = "0.10.2"
version = "0.5.0"
edition = "2024"
[dependencies]
@@ -10,4 +10,5 @@ log = "0.4.20"
tokio = { version = "1.44.2", default-features = false, features = ["fs", "signal", "process", "rt-multi-thread"] }
pcap-file-tokio = "0.1.0"
clap = { version = "4.5.2", features = ["derive"] }
simple_logger = "5.0.0"
walkdir = "2.5.0"

View File

@@ -16,19 +16,19 @@ use walkdir::WalkDir;
#[derive(Parser, Debug)]
#[command(version, about)]
struct Args {
#[arg(short = 'p', long, help = "A file or directory of packet captures")]
#[arg(short = 'p', long)]
path: PathBuf,
#[arg(short = 'P', long, help = "Convert qmdl files to pcap before analysis")]
#[arg(short = 'P', long)]
pcapify: bool,
#[arg(long, help = "Show why some packets were skipped during analysis")]
#[arg(long)]
show_skipped: bool,
#[arg(short, long, help = "Only print warnings/errors to stdout")]
#[arg(short, long)]
quiet: bool,
#[arg(short, long, help = "Show debug messages")]
#[arg(short, long)]
debug: bool,
}
@@ -65,10 +65,10 @@ impl Report {
EventType::Informational => {
info!("{}: INFO - {} {}", self.file_path, timestamp, event.message,);
}
EventType::Low | EventType::Medium | EventType::High => {
EventType::QualitativeWarning { severity } => {
warn!(
"{}: WARNING (Severity: {:?}) - {} {}",
self.file_path, event.event_type, timestamp, event.message,
self.file_path, severity, timestamp, event.message,
);
self.warnings += 1;
}
@@ -177,7 +177,14 @@ async fn main() {
} else {
log::LevelFilter::Info
};
rayhunter::init_logging(level);
simple_logger::SimpleLogger::new()
.with_colors(true)
.without_timestamps()
.with_level(level)
//Filter out a stupid massive amount of uneccesary warnings from hampi about undecoded extensions
.with_module_level("asn1_codecs", log::LevelFilter::Error)
.init()
.unwrap();
let harness = Harness::new_with_config(&AnalyzerConfig::default());
info!("Analyzers:");

View File

@@ -1,23 +1,7 @@
[package]
name = "rayhunter-daemon"
version = "0.10.2"
version = "0.5.0"
edition = "2024"
rust-version = "1.88.0"
[lib]
name = "rayhunter_daemon"
path = "src/lib.rs"
[[bin]]
name = "gen_api"
path = "src/bin/gen_api.rs"
required-features = ["apidocs"]
[features]
default = ["rustcrypto-tls"]
rustcrypto-tls = ["reqwest/rustls-tls-webpki-roots-no-provider", "dep:rustls-rustcrypto"]
pq-tls = ["reqwest/rustls-tls-webpki-roots-no-provider", "dep:rustls-post-quantum"]
apidocs = ["dep:utoipa"]
[dependencies]
rayhunter = { path = "../lib" }
@@ -28,19 +12,16 @@ axum = { version = "0.8", default-features = false, features = ["http1", "tokio"
thiserror = "1.0.52"
libc = "0.2.150"
log = "0.4.20"
env_logger = { version = "0.11", default-features = false }
tokio-util = { version = "0.7.10", features = ["rt", "io", "compat"] }
futures-macro = "0.3.30"
include_dir = "0.7.3"
chrono = { version = "0.4.31", features = ["serde"] }
tokio-stream = { version = "0.1.14", default-features = false, features = ["io-util"] }
tokio-stream = { version = "0.1.14", default-features = false }
futures = { version = "0.3.30", default-features = false }
serde_json = "1.0.114"
image = { version = "0.25.1", default-features = false, features = ["png", "gif"] }
tempfile = "3.10.2"
tempfile = "3.10.1"
async_zip = { version = "0.0.17", features = ["tokio"] }
anyhow = "1.0.98"
reqwest = { version = "0.12.20", default-features = false }
rustls-rustcrypto = { version = "0.0.2-alpha", optional = true }
rustls-post-quantum = { version = "0.2.4", optional = true }
async-trait = "0.1.88"
utoipa = { version = "5.4.0", optional = true }

View File

@@ -1,5 +1,5 @@
use std::sync::Arc;
use std::{cmp, future, pin};
use std::{future, pin};
use axum::Json;
use axum::{
@@ -8,7 +8,7 @@ use axum::{
};
use futures::TryStreamExt;
use log::{error, info};
use rayhunter::analysis::analyzer::{AnalyzerConfig, EventType, Harness};
use rayhunter::analysis::analyzer::{AnalyzerConfig, Harness};
use rayhunter::diag::{DataType, MessagesContainer};
use rayhunter::qmdl::QmdlReader;
use serde::Serialize;
@@ -47,19 +47,15 @@ impl AnalysisWriter {
// Runs the analysis harness on the given container, serializing the results
// to the analysis file, returning the whether any warnings were detected
pub async fn analyze(
&mut self,
container: MessagesContainer,
) -> Result<EventType, std::io::Error> {
let mut max_type = EventType::Informational;
pub async fn analyze(&mut self, container: MessagesContainer) -> Result<bool, std::io::Error> {
let mut warning_detected = false;
for row in self.harness.analyze_qmdl_messages(container) {
if !row.is_empty() {
self.write(&row).await?;
}
max_type = cmp::max(max_type, row.get_max_event_type());
warning_detected |= row.contains_warnings();
}
Ok(max_type)
Ok(warning_detected)
}
async fn write<T: Serialize>(&mut self, value: &T) -> Result<(), std::io::Error> {
@@ -77,15 +73,10 @@ impl AnalysisWriter {
}
}
/// The system status relating to QMDL file analysis
#[derive(Debug, Serialize, Clone)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct AnalysisStatus {
/// The vector array of queued files
queued: Vec<String>,
/// The file currently being analyzed
running: Option<String>,
/// The vector array of finished files
finished: Vec<String>,
}
@@ -220,16 +211,6 @@ pub fn run_analysis_thread(
});
}
#[cfg_attr(feature = "apidocs", utoipa::path(
get,
path = "/api/analysis",
tag = "Recordings",
responses(
(status = StatusCode::OK, description = "Success", body = AnalysisStatus)
),
summary = "Analysis status",
description = "Show analysis status for all QMDL files."
))]
pub async fn get_analysis_status(
State(state): State<Arc<ServerState>>,
) -> Result<Json<AnalysisStatus>, (StatusCode, String)> {
@@ -246,20 +227,6 @@ fn queue_qmdl(name: &str, analysis_status: &mut RwLockWriteGuard<AnalysisStatus>
true
}
#[cfg_attr(feature = "apidocs", utoipa::path(
post,
path = "/api/analysis/{name}",
tag = "Recordings",
responses(
(status = StatusCode::ACCEPTED, description = "Success"),
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Unable to queue analysis file")
),
params(
("name" = String, Path, description = "QMDL file to analyze")
),
summary = "Start analysis",
description = "Begin analysis of QMDL file {name}."
))]
pub async fn start_analysis(
State(state): State<Arc<ServerState>>,
Path(qmdl_name): Path<String>,

View File

@@ -1,121 +0,0 @@
use std::{path::Path, time::Duration};
use log::{info, warn};
use rayhunter::Device;
use serde::Serialize;
use tokio::select;
use tokio_util::{sync::CancellationToken, task::TaskTracker};
use crate::{
error::RayhunterError,
notifications::{Notification, NotificationType},
};
pub mod orbic;
pub mod tmobile;
pub mod tplink;
pub mod wingtech;
const LOW_BATTERY_LEVEL: u8 = 10;
/// Device battery information
#[derive(Clone, Copy, PartialEq, Debug, Serialize)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct BatteryState {
/// The current level in percentage of the device battery
level: u8,
/// A boolean indicating whether the battery is currently being charged
is_plugged_in: bool,
}
async fn is_plugged_in_from_file(path: &Path) -> Result<bool, RayhunterError> {
match tokio::fs::read_to_string(path)
.await
.map_err(RayhunterError::TokioError)?
.chars()
.next()
{
Some('0') => Ok(false),
Some('1') => Ok(true),
_ => Err(RayhunterError::BatteryPluggedInStatusParseError),
}
}
async fn get_level_from_percentage_file(path: &Path) -> Result<u8, RayhunterError> {
tokio::fs::read_to_string(path)
.await
.map_err(RayhunterError::TokioError)?
.trim_end()
.parse()
.or(Err(RayhunterError::BatteryLevelParseError))
}
pub async fn get_battery_status(device: &Device) -> Result<BatteryState, RayhunterError> {
Ok(match device {
Device::Orbic => orbic::get_battery_state().await?,
Device::Wingtech => wingtech::get_battery_state().await?,
Device::Tmobile => tmobile::get_battery_state().await?,
Device::Tplink => tplink::get_battery_state().await?,
_ => return Err(RayhunterError::FunctionNotSupportedForDeviceError),
})
}
pub fn run_battery_notification_worker(
task_tracker: &TaskTracker,
device: Device,
notification_channel: tokio::sync::mpsc::Sender<Notification>,
shutdown_token: CancellationToken,
) {
task_tracker.spawn(async move {
// Don't send a notification initially if the device starts at a low battery level.
let mut triggered = match get_battery_status(&device).await {
Err(RayhunterError::FunctionNotSupportedForDeviceError) => {
info!("Battery status not supported for this device, disabling battery notifications");
return;
}
Err(e) => {
warn!("Failed to get battery status: {e}");
true
}
Ok(status) => status.level <= LOW_BATTERY_LEVEL,
};
loop {
select! {
_ = shutdown_token.cancelled() => break,
_ = tokio::time::sleep(Duration::from_secs(15)) => {}
}
let status = match get_battery_status(&device).await {
Err(RayhunterError::FunctionNotSupportedForDeviceError) => {
info!("Battery status not supported for this device, disabling battery notifications");
break;
}
Err(e) => {
warn!("Failed to get battery status: {e}");
continue;
}
Ok(status) => status,
};
// To avoid flapping, if the notification has already been triggered
// wait until the device has been plugged in and the battery level
// is high enough to re-enable notifications.
if triggered && status.is_plugged_in && status.level > LOW_BATTERY_LEVEL {
triggered = false;
continue;
}
if !triggered && !status.is_plugged_in && status.level <= LOW_BATTERY_LEVEL {
notification_channel
.send(Notification::new(
NotificationType::LowBattery,
"Rayhunter's battery is low".to_string(),
None,
))
.await
.expect("Failed to send to notification channel");
triggered = true;
}
}
});
}

View File

@@ -1,28 +0,0 @@
use std::path::Path;
use crate::{
battery::{BatteryState, is_plugged_in_from_file},
error::RayhunterError,
};
const BATTERY_LEVEL_FILE: &str = "/sys/kernel/chg_info/level";
const PLUGGED_IN_STATE_FILE: &str = "/sys/kernel/chg_info/chg_en";
pub async fn get_battery_state() -> Result<BatteryState, RayhunterError> {
Ok(BatteryState {
level: match tokio::fs::read_to_string(&BATTERY_LEVEL_FILE)
.await
.map_err(RayhunterError::TokioError)?
.chars()
.next()
{
Some('1') => Ok(10),
Some('2') => Ok(25),
Some('3') => Ok(50),
Some('4') => Ok(75),
Some('5') => Ok(100),
_ => Err(RayhunterError::BatteryLevelParseError),
}?,
is_plugged_in: is_plugged_in_from_file(Path::new(PLUGGED_IN_STATE_FILE)).await?,
})
}

View File

@@ -1,16 +0,0 @@
use std::path::Path;
use crate::{
battery::{BatteryState, get_level_from_percentage_file, is_plugged_in_from_file},
error::RayhunterError,
};
const BATTERY_LEVEL_FILE: &str = "/sys/class/power_supply/bms/capacity";
const PLUGGED_IN_STATE_FILE: &str = "/sys/devices/78d9000.usb/power_supply/usb/online";
pub async fn get_battery_state() -> Result<BatteryState, RayhunterError> {
Ok(BatteryState {
level: get_level_from_percentage_file(Path::new(BATTERY_LEVEL_FILE)).await?,
is_plugged_in: is_plugged_in_from_file(Path::new(PLUGGED_IN_STATE_FILE)).await?,
})
}

View File

@@ -1,39 +0,0 @@
use crate::{battery::BatteryState, error::RayhunterError};
pub async fn get_battery_state() -> Result<BatteryState, RayhunterError> {
let uci_battery = tokio::process::Command::new("uci")
.arg("get")
.arg("battery.battery_mgr.power_level")
.output()
.await?;
let uci_plugged_in = tokio::process::Command::new("uci")
.arg("get")
.arg("battery.battery_mgr.is_charging")
.output()
.await?;
if !uci_battery.status.success() {
return Err(RayhunterError::BatteryLevelParseError);
}
if !uci_plugged_in.status.success() {
return Err(RayhunterError::BatteryPluggedInStatusParseError);
}
let uci_battery = String::from_utf8_lossy(&uci_battery.stdout)
.trim_end()
.parse()
.map_err(|_| RayhunterError::BatteryLevelParseError)?;
let uci_plugged_in = match String::from_utf8_lossy(&uci_plugged_in.stdout).trim_end() {
"0" => Ok(false),
"1" => Ok(true),
_ => Err(RayhunterError::BatteryPluggedInStatusParseError),
}?;
Ok(BatteryState {
level: uci_battery,
is_plugged_in: uci_plugged_in,
})
}

View File

@@ -1,17 +0,0 @@
use std::path::Path;
use crate::{
battery::{BatteryState, get_level_from_percentage_file, is_plugged_in_from_file},
error::RayhunterError,
};
const BATTERY_LEVEL_FILE: &str =
"/sys/devices/78b7000.i2c/i2c-3/3-0063/power_supply/cw2017-bat/capacity";
const PLUGGED_IN_STATE_FILE: &str = "/sys/devices/8a00000.ssusb/power_supply/usb/online";
pub async fn get_battery_state() -> Result<BatteryState, RayhunterError> {
Ok(BatteryState {
level: get_level_from_percentage_file(Path::new(BATTERY_LEVEL_FILE)).await?,
is_plugged_in: is_plugged_in_from_file(Path::new(PLUGGED_IN_STATE_FILE)).await?,
})
}

View File

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

View File

@@ -5,35 +5,18 @@ use rayhunter::Device;
use rayhunter::analysis::analyzer::AnalyzerConfig;
use crate::error::RayhunterError;
use crate::notifications::NotificationType;
/// The structure of a valid rayhunter configuration
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct Config {
/// Path to store QMDL files
pub qmdl_store_path: String,
/// Listening port
pub port: u16,
/// Debug mode
pub debug_mode: bool,
/// Internal device name
pub device: Device,
/// UI level
pub ui_level: u8,
/// Colorblind mode
pub colorblind_mode: bool,
/// Key input mode
pub key_input_mode: u8,
/// ntfy.sh URL
pub ntfy_url: Option<String>,
/// Vector containing the types of enabled notifications
pub enabled_notifications: Vec<NotificationType>,
/// Vector containing the list of enabled analyzers
pub analyzers: AnalyzerConfig,
pub min_space_to_start_recording_mb: u64,
pub min_space_to_continue_recording_mb: u64,
}
impl Default for Config {
@@ -47,10 +30,6 @@ impl Default for Config {
colorblind_mode: false,
key_input_mode: 0,
analyzers: AnalyzerConfig::default(),
ntfy_url: None,
enabled_notifications: vec![NotificationType::Warning, NotificationType::LowBattery],
min_space_to_start_recording_mb: 1,
min_space_to_continue_recording_mb: 1,
}
}
}

View File

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

View File

@@ -1,436 +1,143 @@
use std::ops::DerefMut;
use std::pin::pin;
use std::sync::Arc;
use std::time::Duration;
use axum::body::Body;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::http::header::CONTENT_TYPE;
use axum::response::{IntoResponse, Response};
use futures::{StreamExt, TryStreamExt, future};
use futures::{StreamExt, TryStreamExt};
use log::{debug, error, info, warn};
use tokio::fs::File;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::sync::mpsc::{Receiver, Sender};
use tokio::sync::{RwLock, oneshot};
use tokio_stream::wrappers::LinesStream;
use tokio_util::task::TaskTracker;
#[cfg(feature = "apidocs")]
use rayhunter::analysis::analyzer::ReportMetadata;
use rayhunter::analysis::analyzer::{AnalysisLineNormalizer, AnalyzerConfig, EventType};
use rayhunter::diag::{DataType, MessagesContainer};
use rayhunter::analysis::analyzer::AnalyzerConfig;
use rayhunter::diag::DataType;
use rayhunter::diag_device::DiagDevice;
use rayhunter::qmdl::QmdlWriter;
use tokio::fs::File;
use tokio::sync::RwLock;
use tokio::sync::mpsc::{Receiver, Sender};
use tokio_util::io::ReaderStream;
use tokio_util::task::TaskTracker;
use crate::analysis::{AnalysisCtrlMessage, AnalysisWriter};
use crate::display;
use crate::notifications::{Notification, NotificationType};
use crate::qmdl_store::{RecordingStore, RecordingStoreError};
use crate::qmdl_store::{EntryType, RecordingStore, RecordingStoreError};
use crate::server::ServerState;
use crate::stats::DiskStats;
const DISK_CHECK_BYTES_INTERVAL: usize = 256 * 1024;
pub enum DiagDeviceCtrlMessage {
StopRecording,
StartRecording {
response_tx: Option<oneshot::Sender<Result<(), String>>>,
},
DeleteEntry {
name: String,
response_tx: oneshot::Sender<Result<(), RecordingStoreError>>,
},
DeleteAllEntries {
response_tx: oneshot::Sender<Result<(), RecordingStoreError>>,
},
StartRecording,
Exit,
}
pub struct DiagTask {
ui_update_sender: Sender<display::DisplayState>,
analysis_sender: Sender<AnalysisCtrlMessage>,
analyzer_config: AnalyzerConfig,
notification_channel: tokio::sync::mpsc::Sender<Notification>,
min_space_to_start_mb: u64,
min_space_to_continue_mb: u64,
state: DiagState,
max_type_seen: EventType,
bytes_since_space_check: usize,
low_space_warned: bool,
}
enum DiagState {
Recording {
qmdl_writer: QmdlWriter<File>,
analysis_writer: Box<AnalysisWriter>,
},
Stopped,
}
enum DiskSpaceCheck {
Ok(u64),
Warning(u64),
Critical(u64),
Failed,
}
fn check_disk_space(path: &std::path::Path, warning_mb: u64, critical_mb: u64) -> DiskSpaceCheck {
match DiskStats::new(path.to_str().unwrap()) {
Ok(stats) => {
let available_mb = stats.available_bytes.unwrap_or(0) / 1024 / 1024;
if available_mb < critical_mb {
DiskSpaceCheck::Critical(available_mb)
} else if available_mb < warning_mb {
DiskSpaceCheck::Warning(available_mb)
} else {
DiskSpaceCheck::Ok(available_mb)
}
}
Err(e) => {
warn!("Failed to check disk space: {e}");
DiskSpaceCheck::Failed
}
}
}
impl DiagTask {
fn new(
ui_update_sender: Sender<display::DisplayState>,
analysis_sender: Sender<AnalysisCtrlMessage>,
analyzer_config: AnalyzerConfig,
notification_channel: tokio::sync::mpsc::Sender<Notification>,
min_space_to_start_mb: u64,
min_space_to_continue_mb: u64,
) -> Self {
Self {
ui_update_sender,
analysis_sender,
analyzer_config,
notification_channel,
min_space_to_start_mb,
min_space_to_continue_mb,
state: DiagState::Stopped,
max_type_seen: EventType::Informational,
bytes_since_space_check: 0,
low_space_warned: false,
}
}
/// Start recording, returning an error if disk space is too low.
async fn start(&mut self, qmdl_store: &mut RecordingStore) -> Result<(), String> {
self.max_type_seen = EventType::Informational;
self.bytes_since_space_check = 0;
self.low_space_warned = false;
match check_disk_space(
&qmdl_store.path,
self.min_space_to_start_mb,
self.min_space_to_continue_mb,
) {
DiskSpaceCheck::Critical(mb) | DiskSpaceCheck::Warning(mb) => {
let msg = format!(
"Insufficient disk space: {}MB available, {}MB required",
mb, self.min_space_to_start_mb
);
error!("{msg}");
return Err(msg);
}
DiskSpaceCheck::Ok(mb) => {
info!("Starting recording with {}MB disk space available", mb);
}
DiskSpaceCheck::Failed => {}
}
let (qmdl_file, analysis_file) = match qmdl_store.new_entry().await {
Ok(files) => files,
Err(e) => {
let msg = format!("failed creating QMDL file entry: {e}");
error!("{msg}");
return Err(msg);
}
};
self.stop_current_recording().await;
let qmdl_writer = QmdlWriter::new(qmdl_file);
let analysis_writer = match AnalysisWriter::new(analysis_file, &self.analyzer_config).await
{
Ok(writer) => Box::new(writer),
Err(e) => {
let msg = format!("failed to create analysis writer: {e}");
error!("{msg}");
return Err(msg);
}
};
self.state = DiagState::Recording {
qmdl_writer,
analysis_writer,
};
if let Err(e) = self
.ui_update_sender
.send(display::DisplayState::Recording)
.await
{
warn!("couldn't send ui update message: {e}");
}
Ok(())
}
/// Stop recording, optionally annotating the entry with a reason.
async fn stop(&mut self, qmdl_store: &mut RecordingStore, reason: Option<String>) {
self.stop_current_recording().await;
if let Some(reason) = reason
&& let Err(e) = qmdl_store.set_current_stop_reason(reason).await
{
warn!("couldn't set stop reason: {e}");
}
if let Some((_, entry)) = qmdl_store.get_current_entry()
&& let Err(e) = self
.analysis_sender
.send(AnalysisCtrlMessage::RecordingFinished(
entry.name.to_string(),
))
.await
{
warn!("couldn't send analysis message: {e}");
}
if let Err(e) = qmdl_store.close_current_entry().await {
error!("couldn't close current entry: {e}");
}
if let Err(e) = self
.ui_update_sender
.send(display::DisplayState::Paused)
.await
{
warn!("couldn't send ui update message: {e}");
}
}
async fn delete_entry(
&mut self,
qmdl_store: &mut RecordingStore,
name: &str,
) -> Result<(), RecordingStoreError> {
if qmdl_store.is_current_entry(name) {
self.stop(qmdl_store, None).await;
}
let res = qmdl_store.delete_entry(name).await;
if let Err(e) = res.as_ref() {
error!("Error deleting QMDL entry {e}");
}
res
}
async fn delete_all_entries(
&mut self,
qmdl_store: &mut RecordingStore,
) -> Result<(), RecordingStoreError> {
self.stop(qmdl_store, None).await;
let res = qmdl_store.delete_all_entries().await;
if let Err(e) = res.as_ref() {
error!("Error deleting QMDL entries {e}");
}
res
}
async fn stop_current_recording(&mut self) {
let mut state = DiagState::Stopped;
std::mem::swap(&mut self.state, &mut state);
if let DiagState::Recording {
analysis_writer, ..
} = state
{
analysis_writer
.close()
.await
.expect("failed to close analysis writer");
}
}
async fn process_container(
&mut self,
qmdl_store: &mut RecordingStore,
container: MessagesContainer,
) {
if container.data_type != DataType::UserSpace {
debug!("skipping non-userspace diag messages...");
return;
}
// keep track of how many bytes were written to the QMDL file so we can read
// a valid block of data from it in the HTTP server
if let DiagState::Recording {
qmdl_writer,
analysis_writer,
} = &mut self.state
{
if self.bytes_since_space_check >= DISK_CHECK_BYTES_INTERVAL {
self.bytes_since_space_check = 0;
match check_disk_space(
&qmdl_store.path,
self.min_space_to_start_mb,
self.min_space_to_continue_mb,
) {
DiskSpaceCheck::Critical(mb) => {
let reason = format!(
"Disk space critically low ({}MB free), recording stopped automatically",
mb
);
error!("{reason}");
self.notification_channel
.send(Notification::new(
NotificationType::Warning,
reason.clone(),
None,
))
.await
.ok();
self.stop(qmdl_store, Some(reason)).await;
return;
}
DiskSpaceCheck::Warning(mb) if !self.low_space_warned => {
self.low_space_warned = true;
warn!("Disk space low: {}MB remaining", mb);
self.notification_channel
.send(Notification::new(
NotificationType::Warning,
format!("Disk space low: {}MB free", mb),
Some(Duration::from_secs(30)),
))
.await
.ok();
}
_ => {}
}
}
if let Err(e) = qmdl_writer.write_container(&container).await {
let reason = format!("failed to write to QMDL (disk full?): {e}");
error!("{reason}");
self.stop(qmdl_store, Some(reason)).await;
return;
}
debug!(
"total QMDL bytes written: {}, updating manifest...",
qmdl_writer.total_written
);
let index = qmdl_store
.current_entry
.expect("DiagDevice had qmdl_writer, but QmdlStore didn't have current entry???");
if let Err(e) = qmdl_store
.update_entry_qmdl_size(index, qmdl_writer.total_written)
.await
{
let reason = format!("failed to update manifest (disk full?): {e}");
error!("{reason}");
self.stop(qmdl_store, Some(reason)).await;
return;
}
debug!("done!");
let container_bytes: usize = container.messages.iter().map(|m| m.data.len()).sum();
self.bytes_since_space_check += container_bytes;
let max_type = match analysis_writer.analyze(container).await {
Ok(t) => t,
Err(e) => {
warn!("failed to analyze container: {e}");
EventType::Informational
}
};
if max_type > EventType::Informational {
info!("a heuristic triggered on this run!");
self.notification_channel
.send(Notification::new(
NotificationType::Warning,
format!("Rayhunter has detected a {:?} severity event", max_type),
Some(Duration::from_secs(60 * 5)),
))
.await
.expect("Failed to send to notification channel");
}
if max_type > self.max_type_seen {
self.max_type_seen = max_type;
if self.max_type_seen > EventType::Informational {
self.ui_update_sender
.send(display::DisplayState::WarningDetected {
event_type: self.max_type_seen,
})
.await
.expect("couldn't send ui update message: {}");
}
}
} else {
debug!("no qmdl_writer set, continuing...");
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn run_diag_read_thread(
task_tracker: &TaskTracker,
mut dev: DiagDevice,
mut qmdl_file_rx: Receiver<DiagDeviceCtrlMessage>,
qmdl_file_tx: Sender<DiagDeviceCtrlMessage>,
ui_update_sender: Sender<display::DisplayState>,
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
analysis_sender: Sender<AnalysisCtrlMessage>,
analyzer_config: AnalyzerConfig,
notification_channel: tokio::sync::mpsc::Sender<Notification>,
min_space_to_start_mb: u64,
min_space_to_continue_mb: u64,
) {
task_tracker.spawn(async move {
let (initial_qmdl_file, initial_analysis_file) = qmdl_store_lock.write().await.new_entry().await.expect("failed creating QMDL file entry");
let mut maybe_qmdl_writer: Option<QmdlWriter<File>> = Some(QmdlWriter::new(initial_qmdl_file));
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);
qmdl_file_tx
.send(DiagDeviceCtrlMessage::StartRecording { response_tx: None })
.await
.unwrap();
let mut maybe_analysis_writer = Some(AnalysisWriter::new(initial_analysis_file, &analyzer_config).await
.expect("failed to create analysis writer"));
loop {
tokio::select! {
msg = qmdl_file_rx.recv() => {
match msg {
Some(DiagDeviceCtrlMessage::StartRecording { response_tx }) => {
Some(DiagDeviceCtrlMessage::StartRecording) => {
let mut qmdl_store = qmdl_store_lock.write().await;
let result = diag_task.start(qmdl_store.deref_mut()).await;
if let Some(tx) = response_tx {
tx.send(result).ok();
let (qmdl_file, new_analysis_file) = match qmdl_store.new_entry().await {
Ok(x) => x,
Err(e) => {
error!("couldn't create new qmdl entry: {e}");
continue;
}
};
maybe_qmdl_writer = Some(QmdlWriter::new(qmdl_file));
if let Some(analysis_writer) = maybe_analysis_writer {
analysis_writer.close().await.expect("failed to close analysis writer");
}
maybe_analysis_writer = Some(AnalysisWriter::new(new_analysis_file, &analyzer_config).await
.expect("failed to write to analysis file"));
if let Err(e) = ui_update_sender.send(display::DisplayState::Recording).await {
warn!("couldn't send ui update message: {e}");
}
},
Some(DiagDeviceCtrlMessage::StopRecording) => {
let mut qmdl_store = qmdl_store_lock.write().await;
diag_task.stop(qmdl_store.deref_mut(), None).await;
if let Some((_, entry)) = qmdl_store.get_current_entry() {
if let Err(e) = analysis_sender
.send(AnalysisCtrlMessage::RecordingFinished(
entry.name.to_string(),
))
.await {
warn!("couldn't send analysis message: {e}");
}
}
if let Err(e) = qmdl_store.close_current_entry().await {
error!("couldn't close current entry: {e}");
}
maybe_qmdl_writer = None;
if let Some(analysis_writer) = maybe_analysis_writer {
analysis_writer.close().await.expect("failed to close analysis writer");
}
maybe_analysis_writer = None;
if let Err(e) = ui_update_sender.send(display::DisplayState::Paused).await {
warn!("couldn't send ui update message: {e}");
}
},
// None means all the Senders have been dropped, so it's
// time to go
Some(DiagDeviceCtrlMessage::Exit) | None => {
info!("Diag reader thread exiting...");
diag_task.stop_current_recording().await;
if let Some(analysis_writer) = maybe_analysis_writer {
analysis_writer.close().await.expect("failed to close analysis writer");
}
return Ok(())
},
Some(DiagDeviceCtrlMessage::DeleteEntry { name, response_tx }) => {
let mut qmdl_store = qmdl_store_lock.write().await;
let resp = diag_task.delete_entry(qmdl_store.deref_mut(), name.as_str()).await;
if response_tx.send(resp).is_err() {
error!("Failed to send delete entry respons, receiver dropped");
}
},
Some(DiagDeviceCtrlMessage::DeleteAllEntries { response_tx }) => {
let mut qmdl_store = qmdl_store_lock.write().await;
let resp = diag_task.delete_all_entries(qmdl_store.deref_mut()).await;
if response_tx.send(resp).is_err() {
error!("Failed to send delete all entries respons, receiver dropped");
}
},
}
}
maybe_container = diag_stream.next() => {
match maybe_container.unwrap() {
Ok(container) => {
let mut qmdl_store = qmdl_store_lock.write().await;
diag_task.process_container(qmdl_store.deref_mut(), container).await
if container.data_type != DataType::UserSpace {
debug!("skipping non-userspace diag messages...");
continue;
}
// keep track of how many bytes were written to the QMDL file so we can read
// a valid block of data from it in the HTTP server
if let Some(qmdl_writer) = maybe_qmdl_writer.as_mut() {
qmdl_writer.write_container(&container).await.expect("failed to write to QMDL writer");
debug!("total QMDL bytes written: {}, updating manifest...", qmdl_writer.total_written);
let mut qmdl_store = qmdl_store_lock.write().await;
let index = qmdl_store.current_entry.expect("DiagDevice had qmdl_writer, but QmdlStore didn't have current entry???");
qmdl_store.update_entry_qmdl_size(index, qmdl_writer.total_written).await
.expect("failed to update qmdl file size");
debug!("done!");
} else {
debug!("no qmdl_writer set, continuing...");
}
if let Some(analysis_writer) = maybe_analysis_writer.as_mut() {
let heuristic_warning = analysis_writer.analyze(container).await
.expect("failed to analyze container");
if heuristic_warning {
info!("a heuristic triggered on this run!");
ui_update_sender.send(display::DisplayState::WarningDetected).await
.expect("couldn't send ui update message: {}");
}
}
},
Err(err) => {
error!("error reading diag device: {err}");
@@ -443,19 +150,6 @@ pub fn run_diag_read_thread(
});
}
/// Start recording API for web thread
#[cfg_attr(feature = "apidocs", utoipa::path(
post,
path = "/api/start-recording",
tag = "Recordings",
responses(
(status = StatusCode::ACCEPTED, description = "Success"),
(status = StatusCode::FORBIDDEN, description = "System is in debug mode"),
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Recording action unsuccessful")
),
summary = "Start recording",
description = "Begin a new data capture."
))]
pub async fn start_recording(
State(state): State<Arc<ServerState>>,
) -> Result<(StatusCode, String), (StatusCode, String)> {
@@ -463,12 +157,9 @@ pub async fn start_recording(
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
}
let (response_tx, response_rx) = oneshot::channel();
state
.diag_device_ctrl_sender
.send(DiagDeviceCtrlMessage::StartRecording {
response_tx: Some(response_tx),
})
.send(DiagDeviceCtrlMessage::StartRecording)
.await
.map_err(|e| {
(
@@ -477,29 +168,9 @@ pub async fn start_recording(
)
})?;
match response_rx.await {
Ok(Ok(())) => Ok((StatusCode::ACCEPTED, "ok".to_string())),
Ok(Err(reason)) => Err((StatusCode::INSUFFICIENT_STORAGE, reason)),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to receive start recording response: {e}"),
)),
}
Ok((StatusCode::ACCEPTED, "ok".to_string()))
}
/// Stop recording API for web thread
#[cfg_attr(feature = "apidocs", utoipa::path(
post,
path = "/api/stop-recording",
tag = "Recordings",
responses(
(status = StatusCode::ACCEPTED, description = "Success"),
(status = StatusCode::FORBIDDEN, description = "System is in debug mode"),
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Recording action unsuccessful")
),
summary = "Stop recording",
description = "Stop current data capture."
))]
pub async fn stop_recording(
State(state): State<Arc<ServerState>>,
) -> Result<(StatusCode, String), (StatusCode, String)> {
@@ -519,22 +190,6 @@ pub async fn stop_recording(
Ok((StatusCode::ACCEPTED, "ok".to_string()))
}
#[cfg_attr(feature = "apidocs", utoipa::path(
post,
path = "/api/delete-recording/{name}",
tag = "Recordings",
responses(
(status = StatusCode::ACCEPTED, description = "Success"),
(status = StatusCode::FORBIDDEN, description = "System is in debug mode"),
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Delete action unsuccessful"),
(status = StatusCode::BAD_REQUEST, description = "Bad recording name or no such recording")
),
params(
("name" = String, Path, description = "QMDL file to delete")
),
summary = "Delete recording",
description = "Remove data capture file named {name}."
))]
pub async fn delete_recording(
State(state): State<Arc<ServerState>>,
Path(qmdl_name): Path<String>,
@@ -542,27 +197,8 @@ pub async fn delete_recording(
if state.config.debug_mode {
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
}
let (response_tx, response_rx) = oneshot::channel();
state
.diag_device_ctrl_sender
.send(DiagDeviceCtrlMessage::DeleteEntry {
name: qmdl_name.clone(),
response_tx,
})
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send delete entry message: {e}"),
)
})?;
match response_rx.await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to receive delete response: {e}"),
)
})? {
Ok(_) => Ok((StatusCode::ACCEPTED, "ok".to_string())),
let mut qmdl_store = state.qmdl_store_lock.write().await;
match qmdl_store.delete_entry(&qmdl_name).await {
Err(RecordingStoreError::NoSuchEntryError) => Err((
StatusCode::BAD_REQUEST,
format!("no recording with name {qmdl_name}"),
@@ -571,67 +207,70 @@ pub async fn delete_recording(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't delete recording: {e}"),
)),
Ok(entry_type) => {
if entry_type == EntryType::Current {
state
.diag_device_ctrl_sender
.send(DiagDeviceCtrlMessage::StopRecording)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send stop recording message: {e}"),
)
})?;
state
.ui_update_sender
.send(display::DisplayState::Paused)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send ui update message: {e}"),
)
})?;
}
Ok((StatusCode::ACCEPTED, "ok".to_string()))
}
}
}
#[cfg_attr(feature = "apidocs", utoipa::path(
post,
path = "/api/delete-all-recordings",
tag = "Recordings",
responses(
(status = StatusCode::ACCEPTED, description = "Success"),
(status = StatusCode::FORBIDDEN, description = "System is in debug mode"),
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Delete action unsuccessful")
),
summary = "Delete all recordings",
description = "Remove all saved data capture files."
))]
pub async fn delete_all_recordings(
State(state): State<Arc<ServerState>>,
) -> Result<(StatusCode, String), (StatusCode, String)> {
if state.config.debug_mode {
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
}
let (response_tx, response_rx) = oneshot::channel();
state
.diag_device_ctrl_sender
.send(DiagDeviceCtrlMessage::DeleteAllEntries { response_tx })
.send(DiagDeviceCtrlMessage::StopRecording)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send delete all entries message: {e}"),
format!("couldn't send stop recording message: {e}"),
)
})?;
match response_rx.await.map_err(|e| {
let mut qmdl_store = state.qmdl_store_lock.write().await;
qmdl_store.delete_all_entries().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to receive delete all response: {e}"),
format!("couldn't delete all recordings: {e}"),
)
})? {
Ok(_) => Ok((StatusCode::ACCEPTED, "ok".to_string())),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't delete recordings: {e}"),
)),
}
})?;
state
.ui_update_sender
.send(display::DisplayState::Paused)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send ui update message: {e}"),
)
})?;
Ok((StatusCode::ACCEPTED, "ok".to_string()))
}
#[cfg_attr(feature = "apidocs", utoipa::path(
get,
path = "/api/analysis-report/{name}",
tag = "Recordings",
responses(
(status = StatusCode::OK, description = "Success", body = ReportMetadata, content_type = "application/x-ndjson"),
(status = StatusCode::SERVICE_UNAVAILABLE, description = "No QMDL files available; start a new recording."),
(status = StatusCode::NOT_FOUND, description = "File {name} not found")
),
params(
("name" = String, Path, description = "QMDL file to analyze")
),
summary = "Analysis report",
description = "Download processed analysis report for QMDL file {name}, as well as the types (and versions) of analyzers used."
))]
pub async fn get_analysis_report(
State(state): State<Arc<ServerState>>,
Path(qmdl_name): Path<String>,
@@ -652,17 +291,9 @@ pub async fn get_analysis_report(
.open_entry_analysis(entry_index)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?;
// Read and normalize the NDJSON file
let reader = BufReader::new(analysis_file);
let lines_stream = LinesStream::new(reader.lines());
let mut normalizer = AnalysisLineNormalizer::new();
let normalized_stream = lines_stream
.try_filter(|line| future::ready(!line.is_empty()))
.map_ok(move |line| normalizer.normalize_line(line));
let analysis_stream = ReaderStream::new(analysis_file);
let headers = [(CONTENT_TYPE, "application/x-ndjson")];
let body = Body::from_stream(normalized_stream);
let body = Body::from_stream(analysis_stream);
Ok((headers, body).into_response())
}

View File

@@ -1,31 +1,41 @@
use async_trait::async_trait;
use fb_utils::{determine_format, get_var_screeninfo};
use image::{AnimationDecoder, DynamicImage, codecs::gif::GifDecoder, imageops::FilterType};
use std::io::Cursor;
use std::os::fd::{AsFd, BorrowedFd};
use std::time::Duration;
use tokio::fs::File;
use tokio::io::{AsyncSeekExt, AsyncWriteExt};
use crate::config;
use crate::display::DisplayState;
use rayhunter::analysis::analyzer::EventType;
use log::{error, info};
use tokio::sync::mpsc::Receiver;
use tokio_util::{sync::CancellationToken, task::TaskTracker};
use tokio::sync::oneshot;
use tokio::sync::oneshot::error::TryRecvError;
use tokio_util::task::TaskTracker;
use include_dir::{Dir, include_dir};
const REFRESH_RATE: u64 = 1000; //how often in milliseconds to refresh the display
#[derive(Copy, Clone)]
pub struct Dimensions {
pub height: u32,
pub width: u32,
}
#[derive(Copy, Clone)]
pub enum LinePattern {
Solid,
Dashed, // _ _ _ _
Dotted, // . . . .
#[derive(Copy, Clone, Debug)]
pub enum FbFormat {
ARGB888,
ABGR888,
RGB888,
BGR888,
RGB666,
RGB565,
BGR565,
RGB555,
BGR555,
RGB444,
}
#[allow(dead_code)]
@@ -39,7 +49,6 @@ pub enum Color {
Cyan,
Yellow,
Pink,
Orange,
}
impl Color {
@@ -53,33 +62,23 @@ impl Color {
Color::Cyan => (0, 0xff, 0xff),
Color::Yellow => (0xff, 0xff, 0),
Color::Pink => (0xfe, 0x24, 0xff),
Color::Orange => (0xff, 0xa5, 0),
}
}
}
fn display_style_from_state(state: DisplayState, colorblind_mode: bool) -> (Color, LinePattern) {
match state {
DisplayState::Paused => (Color::White, LinePattern::Solid),
DisplayState::Recording => {
if colorblind_mode {
(Color::Blue, LinePattern::Solid)
} else {
(Color::Green, LinePattern::Solid)
}
}
DisplayState::WarningDetected { event_type } => match event_type {
EventType::Informational => {
impl Color {
fn from_state(state: DisplayState, colorblind_mode: bool) -> Self {
match state {
DisplayState::Paused => Color::White,
DisplayState::Recording => {
if colorblind_mode {
(Color::Blue, LinePattern::Solid)
Color::Blue
} else {
(Color::Green, LinePattern::Solid)
Color::Green
}
}
EventType::Low => (Color::Yellow, LinePattern::Dotted),
EventType::Medium => (Color::Orange, LinePattern::Dashed),
EventType::High => (Color::Red, LinePattern::Solid),
},
DisplayState::WarningDetected => Color::Red,
}
}
}
@@ -102,7 +101,7 @@ pub trait GenericFramebuffer: Send + 'static {
resized_img = img;
}
let img_rgba8 = resized_img.as_rgba8().unwrap();
let mut buf = Vec::with_capacity((height * width).try_into().unwrap());
let mut buf = Vec::new();
for y in 0..height {
for x in 0..width {
let px = img_rgba8.get_pixel(x, y);
@@ -139,50 +138,135 @@ pub trait GenericFramebuffer: Send + 'static {
}
async fn draw_line(&mut self, color: Color, height: u32) {
self.draw_patterned_line(color, height, LinePattern::Solid)
.await
}
async fn draw_patterned_line(&mut self, color: Color, height: u32, pattern: LinePattern) {
let width = self.dimensions().width;
let mut buffer = Vec::with_capacity((height * width).try_into().unwrap());
for _row in 0..height {
for col in 0..width {
let should_draw = match pattern {
LinePattern::Solid => true,
LinePattern::Dashed => (col / 4) % 2 == 0, // 4 pixels on, 4 pixels off
LinePattern::Dotted => col % 4 == 0, // 1 pixel on, 3 pixels off
};
if should_draw {
buffer.push(color.rgb());
} else {
buffer.push((0, 0, 0)); // Black background
}
}
let px_num = height * width;
let mut buffer = Vec::new();
for _ in 0..px_num {
buffer.push(color.rgb());
}
self.write_buffer(buffer).await
}
}
/// Attempt to determine the FB dimensions from FB vinfo.
pub fn read_fb_dimentions(fb: BorrowedFd<'_>) -> std::io::Result<Dimensions> {
let vinfo = get_var_screeninfo(fb)?;
Ok(Dimensions {
height: vinfo.yres,
width: vinfo.xres,
})
}
/// Attempt to determine the FBs format
///
/// Returns `Ok(None)` if the format cannot be determined
pub fn read_fb_format(fb: BorrowedFd<'_>) -> std::io::Result<Option<FbFormat>> {
let vinfo = get_var_screeninfo(fb)?;
Ok(determine_format(vinfo))
}
pub fn buffer_to_fb_format(
buffer: &Vec<(u8, u8, u8)>,
format: &FbFormat,
big_endian: bool,
) -> Vec<u8> {
let mut raw_buffer = Vec::new();
for (r, g, b) in buffer {
match format {
FbFormat::RGB565 => {
let mut rgb565: u16 = (*r as u16 & 0b11111000) << 8;
rgb565 |= (*g as u16 & 0b11111100) << 3;
rgb565 |= (*b as u16) >> 3;
if big_endian {
raw_buffer.extend(rgb565.to_be_bytes());
} else {
raw_buffer.extend(rgb565.to_le_bytes());
}
}
other => panic!("This display uses a format we haven't implemneted yet {other:?}"),
}
}
raw_buffer
}
pub type CallBack = Box<dyn FnMut(&mut FbInner, &[(u8, u8, u8)]) + Send + 'static>;
pub struct FramebufferDevice {
data: FbInner,
pre_write_fn: Option<CallBack>,
post_write_fn: Option<CallBack>,
}
pub struct FbInner {
pub fd: File,
pub dims: Dimensions,
pub format: FbFormat,
}
impl FramebufferDevice {
pub fn new(
path: &str,
pre_write_fn: Option<CallBack>,
post_write_fn: Option<CallBack>,
) -> Self {
// This is done as a blocking call to prevent all of the UI init code from having to
// be made async, making it more verbose. This is a single syscall that would have been
// done via spawn_blocking anyway, and it's done once on startup.
let fb = std::fs::File::create(path).expect("Failed to open /dev/fb0");
let dims = read_fb_dimentions(fb.as_fd()).expect("Failed to read FB dimensions");
let format = read_fb_format(fb.as_fd())
.expect("Failed to read FB format")
.expect("FB retruned unexpected format");
Self {
data: FbInner {
fd: File::from_std(fb),
dims,
format,
},
pre_write_fn,
post_write_fn,
}
}
}
#[async_trait]
impl GenericFramebuffer for FramebufferDevice {
fn dimensions(&self) -> Dimensions {
self.data.dims
}
async fn write_buffer(
&mut self,
buffer: Vec<(u8, u8, u8)>, // rgb, row-wise, left-to-right, top-to-bottom
) {
if let Some(func) = self.pre_write_fn.as_mut() {
func(&mut self.data, &buffer);
}
let raw_buffer = buffer_to_fb_format(&buffer, &self.data.format, false);
self.data.fd.write_all(&raw_buffer).await.unwrap();
self.data.fd.rewind().await.unwrap();
if let Some(func) = self.post_write_fn.as_mut() {
func(&mut self.data, &buffer);
}
}
}
pub fn update_ui(
task_tracker: &TaskTracker,
config: &config::Config,
mut fb: impl GenericFramebuffer,
shutdown_token: CancellationToken,
mut ui_shutdown_rx: oneshot::Receiver<()>,
mut ui_update_rx: Receiver<DisplayState>,
) {
static IMAGE_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/images/");
let display_level = config.ui_level;
if display_level == 0 {
info!("Invisible mode, not spawning UI.");
return;
}
let colorblind_mode = config.colorblind_mode;
let mut display_style = display_style_from_state(DisplayState::Recording, colorblind_mode);
let mut display_color = Color::from_state(DisplayState::Recording, colorblind_mode);
task_tracker.spawn(async move {
// this feels wrong, is there a more rusty way to do this?
@@ -203,25 +287,25 @@ pub fn update_ui(
);
}
loop {
if shutdown_token.is_cancelled() {
info!("received UI shutdown");
break;
match ui_shutdown_rx.try_recv() {
Ok(_) => {
info!("received UI shutdown");
break;
}
Err(TryRecvError::Empty) => {}
Err(e) => panic!("error receiving shutdown message: {e}"),
}
match ui_update_rx.try_recv() {
Ok(state) => {
display_style = display_style_from_state(state, colorblind_mode);
display_color = Color::from_state(state, colorblind_mode);
}
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
Err(e) => error!("error receiving framebuffer update message: {e}"),
}
let mut status_bar_height = 2;
match display_level {
2 => fb.draw_gif(img.unwrap()).await,
3 => fb.draw_img(img.unwrap()).await,
4 => {
status_bar_height = fb.dimensions().height;
}
128 => {
fb.draw_line(Color::Cyan, 128).await;
fb.draw_line(Color::Pink, 102).await;
@@ -229,14 +313,193 @@ pub fn update_ui(
fb.draw_line(Color::Pink, 50).await;
fb.draw_line(Color::Cyan, 25).await;
}
// this branch is for ui_level 1, which is also the default if an
// this branch id for ui_level 1, which is also the default if an
// unknown value is used
_ => {}
};
let (color, pattern) = display_style;
fb.draw_patterned_line(color, status_bar_height, pattern)
.await;
tokio::time::sleep(Duration::from_millis(REFRESH_RATE)).await;
fb.draw_line(display_color, 2).await;
tokio::time::sleep(Duration::from_millis(1000)).await;
}
});
}
mod fb_utils {
use std::io::{Error, Result};
use std::os::fd::{AsRawFd, BorrowedFd};
use libc::ioctl;
use super::FbFormat;
const FBIOGET_VSCREENINFO: libc::c_ulong = 0x4600;
// const FBIOGET_FSCREENINFO: libc::c_ulong = 0x4602;
/// Bitfield which is a part of VarScreeninfo.
#[repr(C)]
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Bitfield {
pub offset: u32,
pub length: u32,
pub msb_right: u32,
}
/// Struct as defined in /usr/include/linux/fb.h
#[repr(C)]
#[derive(Clone, Debug, Default)]
pub struct VarScreeninfo {
pub xres: u32,
pub yres: u32,
pub xres_virtual: u32,
pub yres_virtual: u32,
pub xoffset: u32,
pub yoffset: u32,
pub bits_per_pixel: u32,
pub grayscale: u32,
pub red: Bitfield,
pub green: Bitfield,
pub blue: Bitfield,
pub transp: Bitfield,
pub nonstd: u32,
pub activate: u32,
pub height: u32,
pub width: u32,
pub accel_flags: u32,
pub pixclock: u32,
pub left_margin: u32,
pub right_margin: u32,
pub upper_margin: u32,
pub lower_margin: u32,
pub hsync_len: u32,
pub vsync_len: u32,
pub sync: u32,
pub vmode: u32,
pub rotate: u32,
pub colorspace: u32,
pub reserved: [u32; 4],
}
// /// Struct as defined in /usr/include/linux/fb.h
// /// Note: type is a keyword in Rust and therefore has been changed to fb_type.
// #[repr(C)]
// #[derive(Clone, Debug, Default)]
// pub struct FixScreeninfo {
// pub id: [u8; 16],
// pub smem_start: usize,
// pub smem_len: u32,
// pub fb_type: u32,
// pub type_aux: u32,
// pub visual: u32,
// pub xpanstep: u16,
// pub ypanstep: u16,
// pub ywrapstep: u16,
// pub line_length: u32,
// pub mmio_start: usize,
// pub mmio_len: u32,
// pub accel: u32,
// pub capabilities: u16,
// pub reserved: [u16; 2],
// }
// pub fn get_fix_screeninfo(fb: BorrowedFd<'_>) -> Result<FixScreeninfo> {
// let mut info: FixScreeninfo = Default::default();
// let result = unsafe { ioctl(fb.as_raw_fd(), FBIOGET_FSCREENINFO as _, &mut info) };
// match result {
// -1 => Err(Error::last_os_error()),
// _ => Ok(info),
// }
// }
pub fn get_var_screeninfo(fb: BorrowedFd<'_>) -> Result<VarScreeninfo> {
let mut info: VarScreeninfo = Default::default();
let result = unsafe { ioctl(fb.as_raw_fd(), FBIOGET_VSCREENINFO as _, &mut info) };
match result {
-1 => Err(Error::last_os_error()),
_ => Ok(info),
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
struct RgbaBitfield {
red: Bitfield,
green: Bitfield,
blue: Bitfield,
transp: Bitfield,
}
impl From<&VarScreeninfo> for RgbaBitfield {
fn from(value: &VarScreeninfo) -> Self {
Self {
red: value.red.clone(),
green: value.green.clone(),
blue: value.blue.clone(),
transp: value.transp.clone(),
}
}
}
type BitfieldShort = (u32, u32);
type FbInfoShort = (BitfieldShort, BitfieldShort, BitfieldShort, BitfieldShort);
const fn tuple_to_bitfield(v: BitfieldShort) -> Bitfield {
let (offset, length) = v;
// None of formats we support have msb_right set.
Bitfield {
offset,
length,
msb_right: 0,
}
}
/// Takes a tuple of 4 tuples `(r, g, b, a)`. Each color tuple is a tuple of `(offset, length)`.
const fn rgba_bitfield(v: FbInfoShort) -> RgbaBitfield {
let (r, g, b, a) = v;
RgbaBitfield {
red: tuple_to_bitfield(r),
green: tuple_to_bitfield(g),
blue: tuple_to_bitfield(b),
transp: tuple_to_bitfield(a),
}
}
// Logic borrowed from QT https://github.com/qt/qtbase/blob/498ae026e98ed181d1480fe5f6f2f1453a725e78/src/plugins/platforms/linuxfb/qlinuxfbscreen.cpp
const ARGB888: RgbaBitfield = rgba_bitfield(((16, 8), (8, 8), (0, 8), (24, 8)));
const ABGR888: RgbaBitfield = rgba_bitfield(((0, 8), (8, 8), (16, 8), (24, 8)));
const RGB888: RgbaBitfield = rgba_bitfield(((16, 8), (8, 8), (0, 8), (0, 0)));
const BGR888: RgbaBitfield = rgba_bitfield(((0, 8), (8, 8), (16, 8), (0, 0)));
const RGB666: RgbaBitfield = rgba_bitfield(((12, 6), (6, 6), (0, 6), (0, 0)));
const RGB565: RgbaBitfield = rgba_bitfield(((11, 5), (5, 6), (0, 5), (0, 0)));
const BGR565: RgbaBitfield = rgba_bitfield(((0, 5), (5, 6), (11, 5), (0, 0)));
const RGB555: RgbaBitfield = rgba_bitfield(((10, 5), (5, 5), (0, 5), (0, 0)));
const BGR555: RgbaBitfield = rgba_bitfield(((0, 5), (5, 5), (10, 5), (0, 0)));
const RGB444: RgbaBitfield = rgba_bitfield(((8, 4), (4, 4), (0, 4), (0, 0)));
fn determine_depth(vinfo: &VarScreeninfo) -> u32 {
let depth = vinfo.red.length + vinfo.green.length + vinfo.blue.length;
match vinfo.bits_per_pixel {
24 if depth == 0 => 24,
16 if depth == 0 => 16,
24 | 16 => depth,
v => v,
}
}
pub fn determine_format(vinfo: VarScreeninfo) -> Option<FbFormat> {
let rgba = RgbaBitfield::from(&vinfo);
let depth = determine_depth(&vinfo);
match (depth, rgba) {
(32, ARGB888) => Some(FbFormat::ARGB888),
(32, ABGR888) => Some(FbFormat::ABGR888),
(24, RGB888) => Some(FbFormat::RGB888),
(24, BGR888) => Some(FbFormat::BGR888),
(18, RGB666) => Some(FbFormat::RGB666),
(16, RGB565) => Some(FbFormat::RGB565),
(16, BGR565) => Some(FbFormat::BGR565),
(15, RGB555) => Some(FbFormat::RGB555),
(15, BGR555) => Some(FbFormat::BGR555),
(12, RGB444) => Some(FbFormat::RGB444),
_ => None,
}
}
}

View File

@@ -1,6 +1,6 @@
use log::info;
use tokio::sync::mpsc::Receiver;
use tokio_util::sync::CancellationToken;
use tokio::sync::oneshot;
use tokio_util::task::TaskTracker;
use crate::config;
@@ -9,7 +9,7 @@ use crate::display::DisplayState;
pub fn update_ui(
_task_tracker: &TaskTracker,
_config: &config::Config,
_shutdown_token: CancellationToken,
_ui_shutdown_rx: oneshot::Receiver<()>,
_ui_update_rx: Receiver<DisplayState>,
) {
info!("Headless mode, not spawning UI.");

View File

@@ -1,6 +1,3 @@
use rayhunter::analysis::analyzer::EventType;
use serde::{Deserialize, Serialize};
mod generic_framebuffer;
pub mod headless;
@@ -9,20 +6,11 @@ pub mod tmobile;
pub mod tplink;
pub mod tplink_framebuffer;
pub mod tplink_onebit;
pub mod uz801;
pub mod wingtech;
/// A list of available display states
#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
#[derive(Clone, Copy, PartialEq)]
pub enum DisplayState {
/// We're recording but no warning has been found yet.
Recording,
/// We're not recording.
Paused,
/// A non-informational event has been detected.
///
/// Note that EventType::Informational is never sent through this. If it is, it's the same as
/// Recording
WarningDetected { event_type: EventType },
WarningDetected,
}

View File

@@ -1,51 +1,26 @@
use crate::config;
use crate::display::DisplayState;
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
use async_trait::async_trait;
use crate::display::generic_framebuffer;
use tokio::sync::mpsc::Receiver;
use tokio_util::sync::CancellationToken;
use tokio::sync::oneshot;
use tokio_util::task::TaskTracker;
use super::generic_framebuffer::FramebufferDevice;
const FB_PATH: &str = "/dev/fb0";
#[derive(Copy, Clone, Default)]
struct Framebuffer;
#[async_trait]
impl GenericFramebuffer for Framebuffer {
fn dimensions(&self) -> Dimensions {
// TODO actually poll for this, maybe w/ fbset?
Dimensions {
height: 128,
width: 128,
}
}
async fn write_buffer(&mut self, buffer: Vec<(u8, u8, u8)>) {
let mut raw_buffer = Vec::with_capacity(buffer.len() * 2);
for (r, g, b) in buffer {
let mut rgb565: u16 = (r as u16 & 0b11111000) << 8;
rgb565 |= (g as u16 & 0b11111100) << 3;
rgb565 |= (b as u16) >> 3;
raw_buffer.extend(rgb565.to_le_bytes());
}
tokio::fs::write(FB_PATH, &raw_buffer).await.unwrap();
}
}
pub fn update_ui(
task_tracker: &TaskTracker,
config: &config::Config,
shutdown_token: CancellationToken,
ui_shutdown_rx: oneshot::Receiver<()>,
ui_update_rx: Receiver<DisplayState>,
) {
generic_framebuffer::update_ui(
task_tracker,
config,
Framebuffer,
shutdown_token,
FramebufferDevice::new(FB_PATH, None, None),
ui_shutdown_rx,
ui_update_rx,
)
}

View File

@@ -1,10 +1,10 @@
/// Display module for Tmobile TMOHS1, blink LEDs on the front of the device.
/// DisplayState::Recording => Signal LED slowly blinks blue.
/// DisplayState::Paused => WiFi LED blinks white.
/// DisplayState::WarningDetected { .. } => Signal LED slowly blinks red.
/// DisplayState::WarningDetected => Signal LED slowly blinks red.
use log::{error, info};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tokio::sync::oneshot;
use tokio_util::task::TaskTracker;
use std::time::Duration;
@@ -27,7 +27,7 @@ async fn stop_blinking(path: String) {
pub fn update_ui(
task_tracker: &TaskTracker,
config: &config::Config,
shutdown_token: CancellationToken,
mut ui_shutdown_rx: oneshot::Receiver<()>,
mut ui_update_rx: mpsc::Receiver<DisplayState>,
) {
let mut invisible: bool = false;
@@ -40,9 +40,13 @@ pub fn update_ui(
let mut last_state = DisplayState::Paused;
loop {
if shutdown_token.is_cancelled() {
info!("received UI shutdown");
break;
match ui_shutdown_rx.try_recv() {
Ok(_) => {
info!("received UI shutdown");
break;
}
Err(oneshot::error::TryRecvError::Empty) => {}
Err(e) => panic!("error receiving shutdown message: {e}"),
}
match ui_update_rx.try_recv() {
Ok(new_state) => state = new_state,
@@ -64,7 +68,7 @@ pub fn update_ui(
stop_blinking(led!("signal_red")).await;
start_blinking(led!("signal_blue")).await;
}
DisplayState::WarningDetected { .. } => {
DisplayState::WarningDetected => {
stop_blinking(led!("wlan_white")).await;
stop_blinking(led!("signal_blue")).await;
start_blinking(led!("signal_red")).await;

View File

@@ -1,6 +1,6 @@
use log::info;
use tokio::sync::mpsc::Receiver;
use tokio_util::sync::CancellationToken;
use tokio::sync::oneshot;
use tokio_util::task::TaskTracker;
use crate::config;
@@ -11,7 +11,7 @@ use std::fs;
pub fn update_ui(
task_tracker: &TaskTracker,
config: &config::Config,
shutdown_token: CancellationToken,
ui_shutdown_rx: oneshot::Receiver<()>,
ui_update_rx: Receiver<DisplayState>,
) {
let display_level = config.ui_level;
@@ -23,9 +23,9 @@ pub fn update_ui(
// The alternative would be to make the entire initialization async
if fs::exists(tplink_onebit::OLED_PATH).unwrap_or_default() {
info!("detected one-bit display");
tplink_onebit::update_ui(task_tracker, config, shutdown_token, ui_update_rx)
tplink_onebit::update_ui(task_tracker, config, ui_shutdown_rx, ui_update_rx)
} else {
info!("fallback to framebuffer");
tplink_framebuffer::update_ui(task_tracker, config, shutdown_token, ui_update_rx)
tplink_framebuffer::update_ui(task_tracker, config, ui_shutdown_rx, ui_update_rx)
}
}

View File

@@ -1,19 +1,16 @@
use async_trait::async_trait;
use std::os::fd::AsRawFd;
use tokio::fs::OpenOptions;
use tokio::io::AsyncWriteExt;
use tokio_util::sync::CancellationToken;
use crate::config;
use crate::display::DisplayState;
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
use crate::display::generic_framebuffer;
use tokio::sync::mpsc::Receiver;
use tokio::sync::oneshot;
use tokio_util::task::TaskTracker;
const FB_PATH: &str = "/dev/fb0";
use super::generic_framebuffer::{FbInner, FramebufferDevice};
struct Framebuffer;
const FB_PATH: &str = "/dev/fb0";
#[repr(C)]
struct fb_fillrect {
@@ -25,54 +22,28 @@ struct fb_fillrect {
rop: u32,
}
#[async_trait]
impl GenericFramebuffer for Framebuffer {
fn dimensions(&self) -> Dimensions {
// TODO actually poll for this, maybe w/ fbset?
Dimensions {
height: 128,
width: 128,
}
}
fn update_display(fb: &mut FbInner, buffer: &[(u8, u8, u8)]) {
let width = fb.dims.width;
let height = buffer.len() as u32 / width;
let mut arg = fb_fillrect {
dx: 0,
dy: 0,
width,
height,
color: 0xffff, // not sure what this is
rop: 0,
};
async fn write_buffer(&mut self, buffer: Vec<(u8, u8, u8)>) {
// for how to write to the buffer, consult M7350v5_en_gpl/bootable/recovery/recovery_color_oled.c
let dimensions = self.dimensions();
let width = dimensions.width;
let height = buffer.len() as u32 / width;
let mut f = OpenOptions::new().write(true).open(FB_PATH).await.unwrap();
let mut arg = fb_fillrect {
dx: 0,
dy: 0,
width,
height,
color: 0xffff, // not sure what this is
rop: 0,
};
unsafe {
let res = libc::ioctl(
fb.fd.as_raw_fd(),
0x4619, // FBIORECT_DISPLAY
&mut arg as *mut _,
std::mem::size_of::<fb_fillrect>(),
);
let mut raw_buffer = Vec::with_capacity(buffer.len() * 2);
for (r, g, b) in buffer {
let mut rgb565: u16 = (r as u16 & 0b11111000) << 8;
rgb565 |= (g as u16 & 0b11111100) << 3;
rgb565 |= (b as u16) >> 3;
// note: big-endian!
raw_buffer.extend(rgb565.to_be_bytes());
}
f.write_all(&raw_buffer).await.unwrap();
// ioctl is a synchronous operation, but it's fast enough that it shouldn't block
unsafe {
let res = libc::ioctl(
f.as_raw_fd(),
0x4619, // FBIORECT_DISPLAY
&mut arg as *mut _,
std::mem::size_of::<fb_fillrect>(),
);
if res < 0 {
panic!("failed to send FBIORECT_DISPLAY ioctl, {res}");
}
if res < 0 {
panic!("failed to send FBIORECT_DISPLAY ioctl, {res}");
}
}
}
@@ -80,14 +51,14 @@ impl GenericFramebuffer for Framebuffer {
pub fn update_ui(
task_tracker: &TaskTracker,
config: &config::Config,
shutdown_token: CancellationToken,
ui_shutdown_rx: oneshot::Receiver<()>,
ui_update_rx: Receiver<DisplayState>,
) {
generic_framebuffer::update_ui(
task_tracker,
config,
Framebuffer,
shutdown_token,
FramebufferDevice::new(FB_PATH, None, Some(Box::new(update_display))),
ui_shutdown_rx,
ui_update_rx,
)
}

View File

@@ -6,7 +6,8 @@ use crate::display::DisplayState;
use log::{error, info};
use tokio::sync::mpsc::Receiver;
use tokio_util::sync::CancellationToken;
use tokio::sync::oneshot;
use tokio::sync::oneshot::error::TryRecvError;
use tokio_util::task::TaskTracker;
use std::time::Duration;
@@ -111,7 +112,7 @@ const STATUS_WARNING: &[u8] = pixelart! {
pub fn update_ui(
task_tracker: &TaskTracker,
config: &config::Config,
shutdown_token: CancellationToken,
mut ui_shutdown_rx: oneshot::Receiver<()>,
mut ui_update_rx: Receiver<DisplayState>,
) {
let display_level = config.ui_level;
@@ -123,15 +124,19 @@ pub fn update_ui(
let mut pixels = STATUS_SMILING;
loop {
if shutdown_token.is_cancelled() {
info!("received UI shutdown");
break;
match ui_shutdown_rx.try_recv() {
Ok(_) => {
info!("received UI shutdown");
break;
}
Err(TryRecvError::Empty) => {}
Err(e) => panic!("error receiving shutdown message: {e}"),
}
match ui_update_rx.try_recv() {
Ok(DisplayState::Paused) => pixels = STATUS_PAUSED,
Ok(DisplayState::Recording) => pixels = STATUS_SMILING,
Ok(DisplayState::WarningDetected { .. }) => pixels = STATUS_WARNING,
Ok(DisplayState::WarningDetected) => pixels = STATUS_WARNING,
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
Err(e) => {
error!("error receiving framebuffer update message: {e}");
@@ -140,10 +145,10 @@ pub fn update_ui(
// we write the status every second because it may have been overwritten through menu
// navigation.
if display_level != 0
&& let Err(e) = tokio::fs::write(OLED_PATH, pixels).await
{
error!("failed to write to display: {e}");
if display_level != 0 {
if let Err(e) = tokio::fs::write(OLED_PATH, pixels).await {
error!("failed to write to display: {e}");
}
}
tokio::time::sleep(Duration::from_millis(1000)).await;

View File

@@ -1,85 +0,0 @@
/// Display module for Uz801, light LEDs on the front of the device.
/// DisplayState::Recording => Green LED is solid.
/// DisplayState::Paused => Signal LED is solid blue (wifi LED).
/// DisplayState::WarningDetected => Signal LED is solid red.
use log::{error, info};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker;
use std::time::Duration;
use crate::config;
use crate::display::DisplayState;
macro_rules! led {
($l:expr) => {{ format!("/sys/class/leds/{}/brightness", $l) }};
}
async fn led_on(path: String) {
tokio::fs::write(&path, "1").await.ok();
}
async fn led_off(path: String) {
tokio::fs::write(&path, "0").await.ok();
}
pub fn update_ui(
task_tracker: &TaskTracker,
config: &config::Config,
shutdown_token: CancellationToken,
mut ui_update_rx: mpsc::Receiver<DisplayState>,
) {
let mut invisible: bool = false;
if config.ui_level == 0 {
info!("Invisible mode, not spawning UI.");
invisible = true;
}
task_tracker.spawn(async move {
let mut state = DisplayState::Recording;
let mut last_state = DisplayState::Paused;
let mut last_update = std::time::Instant::now();
loop {
if shutdown_token.is_cancelled() {
info!("received UI shutdown");
break;
}
match ui_update_rx.try_recv() {
Ok(new_state) => state = new_state,
Err(mpsc::error::TryRecvError::Empty) => {}
Err(e) => error!("error receiving ui update message: {e}"),
};
// Update LEDs if state changed or if 5 seconds have passed since last update
let now = std::time::Instant::now();
let should_update = !invisible
&& (state != last_state
|| now.duration_since(last_update) >= Duration::from_secs(5));
if should_update {
match state {
DisplayState::Paused => {
led_off(led!("red")).await;
led_off(led!("green")).await;
led_on(led!("wifi")).await;
}
DisplayState::Recording => {
led_off(led!("red")).await;
led_off(led!("wifi")).await;
led_on(led!("green")).await;
}
DisplayState::WarningDetected { .. } => {
led_off(led!("green")).await;
led_off(led!("wifi")).await;
led_on(led!("red")).await;
}
}
last_state = state;
last_update = now;
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
});
}

View File

@@ -1,56 +1,32 @@
use crate::config;
use crate::display::DisplayState;
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
/// Display support for the Wingtech CT2MHS01 hotspot.
///
/// Tested on (from `/etc/wt_version`):
/// WT_INNER_VERSION=SW_Q89323AA1_V057_M10_CRICKET_USR_MP
/// WT_PRODUCTION_VERSION=CT2MHS01_0.04.55
/// WT_HARDWARE_VERSION=89323_1_20
use async_trait::async_trait;
use crate::config;
use crate::display::DisplayState;
use crate::display::generic_framebuffer;
use tokio::sync::mpsc::Receiver;
use tokio_util::sync::CancellationToken;
use tokio::sync::oneshot;
use tokio_util::task::TaskTracker;
use super::generic_framebuffer::FramebufferDevice;
const FB_PATH: &str = "/dev/fb0";
#[derive(Copy, Clone, Default)]
struct Framebuffer;
#[async_trait]
impl GenericFramebuffer for Framebuffer {
fn dimensions(&self) -> Dimensions {
Dimensions {
height: 128,
width: 160,
}
}
async fn write_buffer(&mut self, buffer: Vec<(u8, u8, u8)>) {
let mut raw_buffer = Vec::with_capacity(buffer.len() * 2);
for (r, g, b) in buffer {
let mut rgb565: u16 = (r as u16 & 0b11111000) << 8;
rgb565 |= (g as u16 & 0b11111100) << 3;
rgb565 |= (b as u16) >> 3;
raw_buffer.extend(rgb565.to_le_bytes());
}
tokio::fs::write(FB_PATH, &raw_buffer).await.unwrap();
}
}
pub fn update_ui(
task_tracker: &TaskTracker,
config: &config::Config,
shutdown_token: CancellationToken,
ui_shutdown_rx: oneshot::Receiver<()>,
ui_update_rx: Receiver<DisplayState>,
) {
generic_framebuffer::update_ui(
task_tracker,
config,
Framebuffer,
shutdown_token,
FramebufferDevice::new(FB_PATH, None, None),
ui_shutdown_rx,
ui_update_rx,
)
}

View File

@@ -15,10 +15,4 @@ pub enum RayhunterError {
QmdlStoreError(#[from] RecordingStoreError),
#[error("No QMDL store found at path {0}, but can't create a new one due to debug mode")]
NoStoreDebugMode(String),
#[error("Error parsing file to determine battery level")]
BatteryLevelParseError,
#[error("Error parsing file to determine whether device is plugged in")]
BatteryPluggedInStatusParseError,
#[error("The requested functionality is not supported for this device")]
FunctionNotSupportedForDeviceError,
}

View File

@@ -3,7 +3,7 @@ use std::time::{Duration, Instant};
use tokio::fs::File;
use tokio::io::AsyncReadExt;
use tokio::sync::mpsc::Sender;
use tokio_util::sync::CancellationToken;
use tokio::sync::oneshot;
use tokio_util::task::TaskTracker;
use crate::config;
@@ -21,7 +21,7 @@ pub fn run_key_input_thread(
task_tracker: &TaskTracker,
config: &config::Config,
diag_tx: Sender<DiagDeviceCtrlMessage>,
cancellation_token: CancellationToken,
mut ui_shutdown_rx: oneshot::Receiver<()>,
) {
if config.key_input_mode == 0 {
return;
@@ -43,7 +43,7 @@ pub fn run_key_input_thread(
loop {
tokio::select! {
_ = cancellation_token.cancelled() => {
_ = &mut ui_shutdown_rx => {
info!("received key input shutdown");
return;
}
@@ -61,11 +61,11 @@ pub fn run_key_input_thread(
// On orbic it was observed that pressing the power button can trigger many successive
// events. Drop events that are too close together.
if let Some(last_time) = last_event_time
&& now.duration_since(last_time) < Duration::from_millis(50)
{
last_event_time = Some(now);
continue;
if let Some(last_time) = last_event_time {
if now.duration_since(last_time) < Duration::from_millis(50) {
last_event_time = Some(now);
continue;
}
}
last_event_time = Some(now);
@@ -81,9 +81,8 @@ pub fn run_key_input_thread(
{
error!("Failed to send StopRecording: {e}");
}
if let Err(e) = diag_tx
.send(DiagDeviceCtrlMessage::StartRecording { response_tx: None })
.await
if let Err(e) =
diag_tx.send(DiagDeviceCtrlMessage::StartRecording).await
{
error!("Failed to send StartRecording: {e}");
}

View File

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

View File

@@ -1,12 +1,9 @@
mod analysis;
mod battery;
mod config;
mod crypto_provider;
mod diag;
mod display;
mod error;
mod key_input;
mod notifications;
mod pcap;
mod qmdl_store;
mod server;
@@ -14,18 +11,14 @@ mod stats;
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use crate::battery::run_battery_notification_worker;
use crate::config::{parse_args, parse_config};
use crate::diag::run_diag_read_thread;
use crate::error::RayhunterError;
use crate::notifications::{NotificationService, run_notification_worker};
use crate::pcap::get_pcap;
use crate::qmdl_store::RecordingStore;
use crate::server::{
ServerState, debug_set_display_state, get_config, get_qmdl, get_time, get_zip, serve_static,
set_config, set_time_offset, test_notification,
};
use crate::server::{ServerState, get_config, get_qmdl, get_zip, serve_static, set_config};
use crate::stats::{get_qmdl_manifest, get_system_stats};
use analysis::{
@@ -42,13 +35,11 @@ use log::{error, info};
use qmdl_store::RecordingStoreError;
use rayhunter::Device;
use rayhunter::diag_device::DiagDevice;
use stats::get_log;
use tokio::net::TcpListener;
use tokio::select;
use tokio::sync::RwLock;
use tokio::sync::mpsc::{self, Sender};
use tokio::sync::{RwLock, oneshot};
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker;
type AppRouter = Router<Arc<ServerState>>;
@@ -60,7 +51,6 @@ fn get_router() -> AppRouter {
.route("/api/zip/{name}", get(get_zip))
.route("/api/system-stats", get(get_system_stats))
.route("/api/qmdl-manifest", get(get_qmdl_manifest))
.route("/api/log", get(get_log))
.route("/api/start-recording", post(start_recording))
.route("/api/stop-recording", post(stop_recording))
.route("/api/delete-recording/{name}", post(delete_recording))
@@ -70,10 +60,6 @@ fn get_router() -> AppRouter {
.route("/api/analysis/{name}", post(start_analysis))
.route("/api/config", get(get_config))
.route("/api/config", post(set_config))
.route("/api/test-notification", post(test_notification))
.route("/api/time", get(get_time))
.route("/api/time-offset", post(set_time_offset))
.route("/api/debug/display-state", post(debug_set_display_state))
.route("/", get(|| async { Redirect::permanent("/index.html") }))
.route("/{*path}", get(serve_static))
}
@@ -84,7 +70,7 @@ fn get_router() -> AppRouter {
async fn run_server(
task_tracker: &TaskTracker,
state: Arc<ServerState>,
shutdown_token: CancellationToken,
server_shutdown_rx: oneshot::Receiver<()>,
) -> JoinHandle<()> {
info!("spinning up server");
let addr = SocketAddr::from(([0, 0, 0, 0], state.config.port));
@@ -94,12 +80,17 @@ async fn run_server(
task_tracker.spawn(async move {
info!("The orca is hunting for stingrays...");
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_token.cancelled_owned())
.with_graceful_shutdown(server_shutdown_signal(server_shutdown_rx))
.await
.unwrap();
})
}
async fn server_shutdown_signal(server_shutdown_rx: oneshot::Receiver<()>) {
server_shutdown_rx.await.unwrap();
info!("Server received shutdown signal, exiting...");
}
// Loads a RecordingStore if one exists, and if not, only create one if we're
// not in debug mode. If we fail to parse the manifest AND we're not in debug
// mode, try to recover the manifest from the existing QMDL files
@@ -131,10 +122,15 @@ async fn init_qmdl_store(config: &config::Config) -> Result<RecordingStore, Rayh
// Start a thread that'll track when user hits ctrl+c. When that happens,
// trigger various cleanup tasks, including sending signals to other threads to
// shutdown
#[allow(clippy::too_many_arguments)]
fn run_shutdown_thread(
task_tracker: &TaskTracker,
diag_device_sender: Sender<DiagDeviceCtrlMessage>,
shutdown_token: CancellationToken,
daemon_restart_rx: oneshot::Receiver<()>,
should_restart_flag: Arc<AtomicBool>,
server_shutdown_tx: oneshot::Sender<()>,
maybe_ui_shutdown_tx: Option<oneshot::Sender<()>>,
maybe_key_input_shutdown_tx: Option<oneshot::Sender<()>>,
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
analysis_tx: Sender<AnalysisCtrlMessage>,
) -> JoinHandle<Result<(), RayhunterError>> {
@@ -146,9 +142,17 @@ fn run_shutdown_thread(
if let Err(err) = res {
error!("Unable to listen for shutdown signal: {err}");
}
should_restart_flag.store(false, Ordering::Relaxed);
}
_ = shutdown_token.cancelled() => {}
}
res = daemon_restart_rx => {
if let Err(err) = res {
error!("Unable to listen for shutdown signal: {err}");
}
should_restart_flag.store(true, Ordering::Relaxed);
}
};
let mut qmdl_store = qmdl_store_lock.write().await;
if qmdl_store.current_entry.is_some() {
@@ -157,7 +161,15 @@ fn run_shutdown_thread(
info!("Done!");
}
shutdown_token.cancel();
server_shutdown_tx
.send(())
.expect("couldn't send server shutdown signal");
if let Some(ui_shutdown_tx) = maybe_ui_shutdown_tx {
let _ = ui_shutdown_tx.send(());
}
if let Some(key_input_shutdown_tx) = maybe_key_input_shutdown_tx {
let _ = key_input_shutdown_tx.send(());
}
diag_device_sender
.send(DiagDeviceCtrlMessage::Exit)
.await
@@ -172,9 +184,7 @@ fn run_shutdown_thread(
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), RayhunterError> {
rayhunter::init_logging(log::LevelFilter::Info);
crate::crypto_provider::install_default();
env_logger::init();
let args = parse_args();
@@ -201,16 +211,11 @@ async fn run_with_config(
let (diag_tx, diag_rx) = mpsc::channel::<DiagDeviceCtrlMessage>(1);
let (ui_update_tx, ui_update_rx) = mpsc::channel::<display::DisplayState>(1);
let (analysis_tx, analysis_rx) = mpsc::channel::<AnalysisCtrlMessage>(5);
let restart_token = CancellationToken::new();
let shutdown_token = restart_token.child_token();
// Ensure shutdown_token is cancelled when this function exits for any
// reason (e.g. diag device init failure), so all spawned tasks get
// signaled to stop.
let _shutdown_guard = shutdown_token.clone().drop_guard();
let notification_service = NotificationService::new(config.ntfy_url.clone());
let mut maybe_ui_shutdown_tx = None;
let mut maybe_key_input_shutdown_tx = None;
if !config.debug_mode {
let (ui_shutdown_tx, ui_shutdown_rx) = oneshot::channel();
maybe_ui_shutdown_tx = Some(ui_shutdown_tx);
info!("Using configuration for device: {0:?}", config.device);
let mut dev = DiagDevice::new(&config.device)
.await
@@ -224,14 +229,10 @@ async fn run_with_config(
&task_tracker,
dev,
diag_rx,
diag_tx.clone(),
ui_update_tx.clone(),
qmdl_store_lock.clone(),
analysis_tx.clone(),
config.analyzers.clone(),
notification_service.new_handler(),
config.min_space_to_start_recording_mb,
config.min_space_to_continue_recording_mb,
);
info!("Starting UI");
@@ -241,19 +242,22 @@ async fn run_with_config(
Device::Tmobile => display::tmobile::update_ui,
Device::Wingtech => display::wingtech::update_ui,
Device::Pinephone => display::headless::update_ui,
Device::Uz801 => display::uz801::update_ui,
};
update_ui(&task_tracker, &config, shutdown_token.clone(), ui_update_rx);
update_ui(&task_tracker, &config, ui_shutdown_rx, ui_update_rx);
info!("Starting Key Input service");
let (key_input_shutdown_tx, key_input_shutdown_rx) = oneshot::channel();
maybe_key_input_shutdown_tx = Some(key_input_shutdown_tx);
key_input::run_key_input_thread(
&task_tracker,
&config,
diag_tx.clone(),
shutdown_token.clone(),
key_input_shutdown_rx,
);
}
let (daemon_restart_tx, daemon_restart_rx) = oneshot::channel::<()>();
let (server_shutdown_tx, server_shutdown_rx) = oneshot::channel::<()>();
let analysis_status_lock = Arc::new(RwLock::new(analysis_status));
run_analysis_thread(
&task_tracker,
@@ -262,45 +266,36 @@ async fn run_with_config(
analysis_status_lock.clone(),
config.analyzers.clone(),
);
let should_restart_flag = Arc::new(AtomicBool::new(false));
run_shutdown_thread(
&task_tracker,
diag_tx.clone(),
shutdown_token.clone(),
daemon_restart_rx,
should_restart_flag.clone(),
server_shutdown_tx,
maybe_ui_shutdown_tx,
maybe_key_input_shutdown_tx,
qmdl_store_lock.clone(),
analysis_tx.clone(),
);
run_battery_notification_worker(
&task_tracker,
config.device.clone(),
notification_service.new_handler(),
shutdown_token.clone(),
);
run_notification_worker(
&task_tracker,
notification_service,
config.enabled_notifications.clone(),
);
let state = Arc::new(ServerState {
config_path: args.config_path.clone(),
config,
qmdl_store_lock: qmdl_store_lock.clone(),
diag_device_ctrl_sender: diag_tx,
ui_update_sender: ui_update_tx,
analysis_status_lock,
analysis_sender: analysis_tx,
daemon_restart_token: restart_token.clone(),
ui_update_sender: Some(ui_update_tx),
daemon_restart_tx: Arc::new(RwLock::new(Some(daemon_restart_tx))),
});
run_server(&task_tracker, state, shutdown_token.clone()).await;
run_server(&task_tracker, state, server_shutdown_rx).await;
task_tracker.close();
task_tracker.wait().await;
info!("see you space cowboy...");
Ok(restart_token.is_cancelled())
Ok(should_restart_flag.load(Ordering::Relaxed))
}
#[cfg(test)]

View File

@@ -1,444 +0,0 @@
use std::{
cmp::min,
collections::HashMap,
time::{Duration, Instant},
};
use log::error;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tokio::sync::mpsc::{self, error::TryRecvError};
use tokio_util::task::TaskTracker;
pub const DEFAULT_NOTIFICATION_TIMEOUT: u64 = 10; //seconds
#[derive(Error, Debug)]
pub enum NotificationError {
#[error("HTTP request failed: {0}")]
RequestFailed(#[from] reqwest::Error),
#[error("Server returned error status: {0}")]
HttpError(reqwest::StatusCode),
}
/// Enum of valid notification types
#[derive(Hash, Eq, PartialEq, Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub enum NotificationType {
Warning,
LowBattery,
}
pub struct Notification {
notification_type: NotificationType,
message: String,
debounce: Option<Duration>,
}
impl Notification {
pub fn new(
notification_type: NotificationType,
message: String,
debounce: Option<Duration>,
) -> Self {
Notification {
notification_type,
message,
debounce,
}
}
}
struct NotificationStatus {
message: String,
needs_sending: bool,
last_sent: Option<Instant>,
last_attempt: Option<Instant>,
failed_since_last_success: u32,
}
pub struct NotificationService {
url: Option<String>,
timeout: u64,
tx: mpsc::Sender<Notification>,
rx: mpsc::Receiver<Notification>,
}
impl NotificationService {
pub fn new(url: Option<String>) -> Self {
let (tx, rx) = mpsc::channel(10);
Self {
url,
timeout: DEFAULT_NOTIFICATION_TIMEOUT,
tx,
rx,
}
}
pub fn new_handler(&self) -> mpsc::Sender<Notification> {
self.tx.clone()
}
}
/// Sends a notification message to the specified URL.
pub async fn send_notification(
http_client: &reqwest::Client,
url: &str,
message: String,
timeout: u64,
) -> Result<(), NotificationError> {
let response = http_client
.post(url)
.body(message)
.timeout(Duration::from_secs(timeout))
.send()
.await?;
if response.status().is_success() {
Ok(())
} else {
Err(NotificationError::HttpError(response.status()))
}
}
pub fn run_notification_worker(
task_tracker: &TaskTracker,
mut notification_service: NotificationService,
enabled_notifications: Vec<NotificationType>,
) {
task_tracker.spawn(async move {
if let Some(url) = notification_service.url
&& !url.is_empty()
{
let mut notification_statuses = HashMap::new();
let http_client = reqwest::Client::new();
loop {
// Get any notifications since the last time we checked
loop {
match notification_service.rx.try_recv() {
Ok(notification) => {
if !enabled_notifications.contains(&notification.notification_type) {
continue;
}
let status = notification_statuses
.entry(notification.notification_type)
.or_insert_with(|| NotificationStatus {
message: "".to_string(),
needs_sending: true,
last_sent: None,
last_attempt: None,
failed_since_last_success: 0,
});
// Ignore if we're in the debounce period
if let Some(debounce) = notification.debounce
&& let Some(last_sent) = status.last_sent
&& last_sent.elapsed() < debounce
{
continue;
}
status.message = notification.message;
status.needs_sending = true;
}
Err(TryRecvError::Empty) => {
break;
}
Err(TryRecvError::Disconnected) => {
return;
}
}
}
// Attempt to send pending notifications
for notification in notification_statuses.values_mut() {
if !notification.needs_sending {
continue;
}
// Backoff retries, up to a maximum of 256 seconds.
if let Some(last_attempt) = notification.last_attempt {
let min_wait_time = Duration::from_secs(
2u64.pow(min(notification.failed_since_last_success, 8)),
);
if last_attempt.elapsed() < min_wait_time {
continue;
}
}
match send_notification(
&http_client,
&url,
notification.message.clone(),
notification_service.timeout,
)
.await
{
Ok(()) => {
notification.last_sent = Some(Instant::now());
notification.failed_since_last_success = 0;
notification.needs_sending = false;
}
Err(e) => {
error!("Failed to send notification: {e}");
notification.failed_since_last_success += 1;
notification.last_attempt = Some(Instant::now());
}
}
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
}
// If there's no url to send to we'll just discard the notifications
else {
loop {
if notification_service.rx.recv().await.is_none() {
break;
}
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{Router, body::Bytes, extract::State, routing::post};
use std::sync::Arc;
use tokio::net::TcpListener;
use tokio::sync::Mutex;
#[derive(Clone)]
struct TestServerState {
received_messages: Arc<Mutex<Vec<String>>>,
}
async fn capture_notification(
State(state): State<TestServerState>,
body: Bytes,
) -> &'static str {
let message = String::from_utf8_lossy(&body).to_string();
state.received_messages.lock().await.push(message);
"OK"
}
async fn setup_test_server() -> (Arc<Mutex<Vec<String>>>, String) {
crate::crypto_provider::install_default();
let received_messages = Arc::new(Mutex::new(Vec::new()));
let test_state = TestServerState {
received_messages: received_messages.clone(),
};
let app = Router::new()
.route("/", post(capture_notification))
.with_state(test_state);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let url = format!("http://{}", addr);
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
tokio::time::sleep(Duration::from_millis(100)).await;
(received_messages, url)
}
async fn setup_timeout_server(timeout: u64) -> String {
#[cfg(feature = "rustcrypto-tls")]
{
let _ = rustls_rustcrypto::provider().install_default();
}
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let url = format!("http://{}", addr);
tokio::spawn(async move {
// Accept the connection but don't respond in the timeout
let (_socket, _addr) = listener.accept().await.unwrap();
tokio::time::sleep(Duration::from_secs(timeout * 2)).await;
});
tokio::time::sleep(Duration::from_millis(100)).await;
url
}
async fn cleanup_worker(sender: mpsc::Sender<Notification>, tracker: TaskTracker) {
drop(sender);
tracker.close();
tracker.wait().await;
}
#[tokio::test]
async fn test_send_notification_times_out() {
let timeout: u64 = 2;
let url = setup_timeout_server(timeout).await;
let http_client = reqwest::Client::new();
let result = send_notification(
&http_client,
&url,
"test warning message".to_string(),
timeout,
)
.await;
match result {
Err(NotificationError::RequestFailed(reqwest_error)) => {
println!("error = {:?}", reqwest_error);
assert!(reqwest_error.is_timeout());
}
_ => assert!(false),
}
}
#[tokio::test]
async fn test_notification_worker_sends_message() {
let (received_messages, url) = setup_test_server().await;
let task_tracker = TaskTracker::new();
let notification_service = NotificationService::new(Some(url));
let notification_sender = notification_service.new_handler();
run_notification_worker(
&task_tracker,
notification_service,
vec![NotificationType::Warning],
);
notification_sender
.send(Notification::new(
NotificationType::Warning,
"test warning message".to_string(),
None,
))
.await
.unwrap();
tokio::time::sleep(Duration::from_secs(3)).await;
let messages = received_messages.lock().await;
assert_eq!(messages.len(), 1);
assert_eq!(messages[0], "test warning message");
drop(messages);
cleanup_worker(notification_sender, task_tracker).await;
}
#[tokio::test]
async fn test_notification_worker_filters_disabled_types() {
let (received_messages, url) = setup_test_server().await;
let task_tracker = TaskTracker::new();
let notification_service = NotificationService::new(Some(url));
let notification_sender = notification_service.new_handler();
run_notification_worker(
&task_tracker,
notification_service,
vec![NotificationType::Warning],
);
notification_sender
.send(Notification::new(
NotificationType::Warning,
"test warning".to_string(),
None,
))
.await
.unwrap();
notification_sender
.send(Notification::new(
NotificationType::LowBattery,
"test low battery".to_string(),
None,
))
.await
.unwrap();
tokio::time::sleep(Duration::from_secs(3)).await;
let messages = received_messages.lock().await;
assert_eq!(messages.len(), 1);
assert_eq!(messages[0], "test warning");
drop(messages);
cleanup_worker(notification_sender, task_tracker).await;
}
#[tokio::test]
async fn test_notification_worker_sends_enabled_types() {
let (received_messages, url) = setup_test_server().await;
let task_tracker = TaskTracker::new();
let notification_service = NotificationService::new(Some(url));
let notification_sender = notification_service.new_handler();
run_notification_worker(
&task_tracker,
notification_service,
vec![NotificationType::Warning, NotificationType::LowBattery],
);
notification_sender
.send(Notification::new(
NotificationType::Warning,
"test warning".to_string(),
None,
))
.await
.unwrap();
notification_sender
.send(Notification::new(
NotificationType::LowBattery,
"test low battery".to_string(),
None,
))
.await
.unwrap();
tokio::time::sleep(Duration::from_secs(3)).await;
let messages = received_messages.lock().await;
assert_eq!(messages.len(), 2);
// these are interchangeable, ordering not guaranteed
assert!(messages.contains(&"test warning".to_string()));
assert!(messages.contains(&"test low battery".to_string()));
drop(messages);
cleanup_worker(notification_sender, task_tracker).await;
}
#[tokio::test]
async fn test_notification_worker_with_no_url() {
let task_tracker = TaskTracker::new();
let notification_service = NotificationService::new(None);
let notification_sender = notification_service.new_handler();
run_notification_worker(
&task_tracker,
notification_service,
vec![NotificationType::Warning],
);
notification_sender
.send(Notification::new(
NotificationType::Warning,
"test warning".to_string(),
None,
))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(500)).await;
cleanup_worker(notification_sender, task_tracker).await;
}
}

View File

@@ -1,4 +1,4 @@
use crate::server::ServerState;
use crate::ServerState;
use anyhow::Error;
use axum::body::Body;
@@ -18,21 +18,6 @@ use tokio_util::io::ReaderStream;
// Streams a pcap file chunk-by-chunk to the client by reading the QMDL data
// written so far. This is done by spawning a thread which streams chunks of
// pcap data to a channel that's piped to the client.
#[cfg_attr(feature = "apidocs", utoipa::path(
get,
path = "/api/pcap/{name}",
tag = "Recordings",
responses(
(status = StatusCode::OK, description = "PCAP conversion successful", content_type = "application/vnd.tcpdump.pcap"),
(status = StatusCode::NOT_FOUND, description = "Could not find file {name}"),
(status = StatusCode::SERVICE_UNAVAILABLE, description = "QMDL file is empty")
),
params(
("name" = String, Path, description = "QMDL filename to convert and download")
),
summary = "Download a PCAP file",
description = "Stream a PCAP file to a client in chunks by converting the QMDL data for file {name} written so far."
))]
pub async fn get_pcap(
State(state): State<Arc<ServerState>>,
Path(mut qmdl_name): Path<String>,

View File

@@ -45,33 +45,26 @@ pub struct Manifest {
pub entries: Vec<ManifestEntry>,
}
/// The structure of an entry in the QMDL manifest table
#[derive(Deserialize, Serialize, Clone, PartialEq, Debug)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct ManifestEntry {
/// The name of the entry
pub name: String,
/// The system time when recording began
#[cfg_attr(feature = "apidocs", schema(value_type = String))]
pub start_time: DateTime<Local>,
/// The system time when the last message was recorded to the file
#[cfg_attr(feature = "apidocs", schema(value_type = String))]
pub last_message_time: Option<DateTime<Local>>,
/// The size of the QMDL file in bytes
pub qmdl_size_bytes: usize,
/// The rayhunter daemon version which generated the file
pub rayhunter_version: Option<String>,
/// The OS which created the file
pub system_os: Option<String>,
/// The architecture on which the OS was running
pub arch: Option<String>,
#[serde(default)]
pub stop_reason: Option<String>,
}
#[derive(PartialEq, Eq)]
pub enum EntryType {
Current,
Past,
}
impl ManifestEntry {
fn new() -> Self {
let now = rayhunter::clock::get_adjusted_now();
let now = Local::now();
let metadata = RuntimeMetadata::new();
ManifestEntry {
name: format!("{}", now.timestamp()),
@@ -81,7 +74,6 @@ impl ManifestEntry {
rayhunter_version: Some(metadata.rayhunter_version),
system_os: Some(metadata.system_os),
arch: Some(metadata.arch),
stop_reason: None,
}
}
@@ -211,12 +203,11 @@ impl RecordingStore {
rayhunter_version: None,
system_os: None,
arch: None,
stop_reason: None,
});
}
// sort chronologically
manifest_entries.sort_by_key(|a| a.start_time);
manifest_entries.sort_by(|a, b| a.start_time.cmp(&b.start_time));
let mut store = RecordingStore {
path: path.as_ref().to_path_buf(),
@@ -315,8 +306,7 @@ impl RecordingStore {
size_bytes: usize,
) -> Result<(), RecordingStoreError> {
self.manifest.entries[entry_index].qmdl_size_bytes = size_bytes;
self.manifest.entries[entry_index].last_message_time =
Some(rayhunter::clock::get_adjusted_now());
self.manifest.entries[entry_index].last_message_time = Some(Local::now());
self.write_manifest().await
}
@@ -357,42 +347,23 @@ impl RecordingStore {
Some((entry_index, &self.manifest.entries[entry_index]))
}
pub async fn set_current_stop_reason(
&mut self,
reason: String,
) -> Result<(), RecordingStoreError> {
if let Some(idx) = self.current_entry {
self.manifest.entries[idx].stop_reason = Some(reason);
self.write_manifest().await?;
}
Ok(())
}
pub fn is_current_entry(&self, name: &str) -> bool {
match self.current_entry {
Some(idx) => match self.manifest.entries.get(idx) {
Some(entry) => entry.name == name,
None => false,
},
None => false,
}
}
pub async fn delete_entry(&mut self, name: &str) -> Result<(), RecordingStoreError> {
pub async fn delete_entry(&mut self, name: &str) -> Result<EntryType, RecordingStoreError> {
let entry_to_delete_idx = self
.manifest
.entries
.iter()
.position(|entry| entry.name == name)
.ok_or(RecordingStoreError::NoSuchEntryError)?;
match self.current_entry {
let is_current = match self.current_entry {
Some(current_entry) if current_entry == entry_to_delete_idx => {
self.close_current_entry().await?;
EntryType::Current
}
Some(current_entry) => {
self.current_entry = Some(current_entry - 1);
EntryType::Past
}
None => {}
None => EntryType::Past,
};
let entry_to_delete = self.manifest.entries.remove(entry_to_delete_idx);
self.write_manifest().await?;
@@ -404,7 +375,7 @@ impl RecordingStore {
remove_file_if_exists(&analysis_filepath)
.await
.map_err(RecordingStoreError::DeleteFileError)?;
Ok(())
Ok(is_current)
}
pub async fn delete_all_entries(&mut self) -> Result<(), RecordingStoreError> {

View File

@@ -9,52 +9,32 @@ use axum::extract::State;
use axum::http::header::{self, CONTENT_LENGTH, CONTENT_TYPE};
use axum::http::{HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use chrono::{DateTime, Local};
use log::{error, warn};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::fs::write;
use tokio::io::{AsyncReadExt, copy, duplex};
use tokio::sync::RwLock;
use tokio::sync::mpsc::Sender;
use tokio::sync::{RwLock, oneshot};
use tokio_util::compat::FuturesAsyncWriteCompatExt;
use tokio_util::io::ReaderStream;
use tokio_util::sync::CancellationToken;
use crate::analysis::{AnalysisCtrlMessage, AnalysisStatus};
use crate::config::Config;
use crate::diag::DiagDeviceCtrlMessage;
use crate::display::DisplayState;
use crate::notifications::DEFAULT_NOTIFICATION_TIMEOUT;
use crate::pcap::generate_pcap_data;
use crate::qmdl_store::RecordingStore;
use crate::{DiagDeviceCtrlMessage, display};
pub struct ServerState {
pub config_path: String,
pub config: Config,
pub qmdl_store_lock: Arc<RwLock<RecordingStore>>,
pub diag_device_ctrl_sender: Sender<DiagDeviceCtrlMessage>,
pub ui_update_sender: Sender<display::DisplayState>,
pub analysis_status_lock: Arc<RwLock<AnalysisStatus>>,
pub analysis_sender: Sender<AnalysisCtrlMessage>,
pub daemon_restart_token: CancellationToken,
pub ui_update_sender: Option<Sender<DisplayState>>,
pub daemon_restart_tx: Arc<RwLock<Option<oneshot::Sender<()>>>>,
}
#[cfg_attr(feature = "apidocs", utoipa::path(
get,
path = "/api/qmdl/{name}",
tag = "Recordings",
responses(
(status = StatusCode::OK, description = "QMDL download successful", content_type = "application/octet-stream"),
(status = StatusCode::NOT_FOUND, description = "Could not find file {name}"),
(status = StatusCode::SERVICE_UNAVAILABLE, description = "QMDL file is empty, or error opening file")
),
params(
("name" = String, Path, description = "QMDL filename to convert and download")
),
summary = "Download a QMDL file",
description = "Stream the QMDL file {name} to the client."
))]
pub async fn get_qmdl(
State(state): State<Arc<ServerState>>,
Path(qmdl_name): Path<String>,
@@ -92,6 +72,11 @@ pub async fn serve_static(
let path = path.trim_start_matches('/');
match path {
"rayhunter_icon.png" => (
[(header::CONTENT_TYPE, HeaderValue::from_static("image/png"))],
include_bytes!("../web/build/rayhunter_icon.png"),
)
.into_response(),
"rayhunter_orca_only.png" => (
[(header::CONTENT_TYPE, HeaderValue::from_static("image/png"))],
include_bytes!("../web/build/rayhunter_orca_only.png"),
@@ -122,38 +107,12 @@ pub async fn serve_static(
}
}
#[cfg_attr(feature = "apidocs", utoipa::path(
get,
path = "/api/config",
tag = "Configuration",
responses(
(status = StatusCode::OK, description = "Success", body = Config)
),
summary = "Get config",
description = "Show the running configuration for Rayhunter."
))]
pub async fn get_config(
State(state): State<Arc<ServerState>>,
) -> Result<Json<Config>, (StatusCode, String)> {
Ok(Json(state.config.clone()))
}
#[cfg_attr(feature = "apidocs", utoipa::path(
post,
path = "/api/config",
tag = "Configuration",
request_body(
content = Option<[Config]>,
description = "Any or all configuration elements from the valid config schema to be altered may be passed. Invalid keys will be discarded. Invalid values or value types will return an error."
),
responses(
(status = StatusCode::ACCEPTED, description = "Success"),
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Failed to parse or write config file"),
(status = 422, description = "Failed to deserialize JSON body")
),
summary = "Set config",
description = "Write a new configuration for Rayhunter and trigger a restart."
))]
pub async fn set_config(
State(state): State<Arc<ServerState>>,
Json(config): Json<Config>,
@@ -173,142 +132,26 @@ pub async fn set_config(
})?;
// Trigger daemon restart after writing config
state.daemon_restart_token.cancel();
Ok((
StatusCode::ACCEPTED,
"wrote config and triggered restart".to_string(),
))
}
#[cfg_attr(feature = "apidocs", utoipa::path(
post,
path = "/api/test-notification",
tag = "Configuration",
responses(
(status = StatusCode::OK, description = "Success"),
(status = StatusCode::BAD_REQUEST, description = "No notification URL set"),
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Failed to send HTTP request. Ensure your device can reach the internet.")
),
summary = "Test ntfy notification",
description = "Send a test notification to the ntfy_url in the running configuration for Rayhunter."
))]
pub async fn test_notification(
State(state): State<Arc<ServerState>>,
) -> Result<(StatusCode, String), (StatusCode, String)> {
let url = state.config.ntfy_url.as_ref().ok_or((
StatusCode::BAD_REQUEST,
"No notification URL configured".to_string(),
))?;
if url.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
"Notification URL is empty".to_string(),
));
let mut restart_tx = state.daemon_restart_tx.write().await;
if let Some(sender) = restart_tx.take() {
sender.send(()).map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
"couldn't send restart signal".to_string(),
)
})?;
Ok((
StatusCode::ACCEPTED,
"wrote config and triggered restart".to_string(),
))
} else {
Ok((
StatusCode::ACCEPTED,
"wrote config but restart already triggered".to_string(),
))
}
let http_client = reqwest::Client::new();
let message = "Test notification from Rayhunter".to_string();
crate::notifications::send_notification(
&http_client,
url,
message,
DEFAULT_NOTIFICATION_TIMEOUT,
)
.await
.map(|()| {
(
StatusCode::OK,
"Test notification sent successfully".to_string(),
)
})
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to send test notification: {e}"),
)
})
}
/// Response for GET /api/time
#[derive(Serialize)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct TimeResponse {
/// The raw system time (without clock offset)
#[cfg_attr(feature = "apidocs", schema(value_type = String))]
pub system_time: DateTime<Local>,
/// The adjusted time (system time + offset)
#[cfg_attr(feature = "apidocs", schema(value_type = String))]
pub adjusted_time: DateTime<Local>,
/// The current offset in seconds
pub offset_seconds: i64,
}
/// Request for POST /api/time-offset
#[derive(Deserialize)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct SetTimeOffsetRequest {
/// The offset to set, in seconds
pub offset_seconds: i64,
}
#[cfg_attr(feature = "apidocs", utoipa::path(
get,
path = "/api/time",
tag = "Configuration",
responses(
(status = StatusCode::OK, description = "Success", body = TimeResponse)
),
summary = "Get time",
description = "Get the current time and offset (in seconds) of the device."
))]
pub async fn get_time() -> Json<TimeResponse> {
let system_time = Local::now();
let adjusted_time = rayhunter::clock::get_adjusted_now();
let offset_seconds = adjusted_time
.signed_duration_since(system_time)
.num_seconds();
Json(TimeResponse {
system_time,
adjusted_time,
offset_seconds,
})
}
#[cfg_attr(feature = "apidocs", utoipa::path(
get,
path = "/api/time-offset",
tag = "Configuration",
request_body(
content = SetTimeOffsetRequest
),
responses(
(status = StatusCode::OK, description = "Success", body = TimeResponse)
),
summary = "Set time offset",
description = "Set the difference (in seconds) between the system time and the adjusted time for Rayhunter."
))]
pub async fn set_time_offset(Json(req): Json<SetTimeOffsetRequest>) -> StatusCode {
rayhunter::clock::set_offset(chrono::TimeDelta::seconds(req.offset_seconds));
StatusCode::OK
}
#[cfg_attr(feature = "apidocs", utoipa::path(
get,
path = "/api/zip/{name}",
tag = "Recordings",
responses(
(status = StatusCode::OK, description = "ZIP download successful. It is possible that if the PCAP fails to convert, the same status will be returned, but the file will contain only the QMDL file.", content_type = "application/zip"),
(status = StatusCode::NOT_FOUND, description = "Could not find file {name}"),
(status = StatusCode::SERVICE_UNAVAILABLE, description = "QMDL file is empty, or error opening file")
),
params(
("name" = String, Path, description = "QMDL filename to convert and download")
),
summary = "Download a ZIP file",
description = "Stream a ZIP file to the client which contains the QMDL file {name} and a PCAP generated from the same file."
))]
pub async fn get_zip(
State(state): State<Arc<ServerState>>,
Path(entry_name): Path<String>,
@@ -400,44 +243,6 @@ pub async fn get_zip(
Ok((headers, body).into_response())
}
#[cfg_attr(feature = "apidocs", utoipa::path(
post,
path = "/api/debug/display-state",
tag = "Configuration",
request_body(
content = DisplayState
),
responses(
(status = StatusCode::OK, description = "Display state updated successfully"),
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Error sending update to the display"),
(status = StatusCode::SERVICE_UNAVAILABLE, description = "Display system not available")
),
summary = "Set display state",
description = "Change the display state (color bar or otherwise) of the device for debugging purposes."
))]
pub async fn debug_set_display_state(
State(state): State<Arc<ServerState>>,
Json(display_state): Json<DisplayState>,
) -> Result<(StatusCode, String), (StatusCode, String)> {
if let Some(ui_sender) = &state.ui_update_sender {
ui_sender.send(display_state).await.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
"failed to send display state update".to_string(),
)
})?;
Ok((
StatusCode::OK,
"display state updated successfully".to_string(),
))
} else {
Err((
StatusCode::SERVICE_UNAVAILABLE,
"display system not available".to_string(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -488,6 +293,7 @@ mod tests {
store_lock: Arc<RwLock<crate::qmdl_store::RecordingStore>>,
) -> Arc<ServerState> {
let (tx, _rx) = tokio::sync::mpsc::channel(1);
let (ui_tx, _ui_rx) = tokio::sync::mpsc::channel(1);
let (analysis_tx, _analysis_rx) = tokio::sync::mpsc::channel(1);
let analysis_status = {
@@ -500,10 +306,10 @@ mod tests {
config: Config::default(),
qmdl_store_lock: store_lock,
diag_device_ctrl_sender: tx,
ui_update_sender: ui_tx,
analysis_status_lock: Arc::new(RwLock::new(analysis_status)),
analysis_sender: analysis_tx,
daemon_restart_token: CancellationToken::new(),
ui_update_sender: None,
daemon_restart_tx: Arc::new(RwLock::new(None)),
})
}

View File

@@ -1,114 +1,67 @@
use std::ffi::CString;
use std::sync::Arc;
use crate::battery::get_battery_status;
use crate::error::RayhunterError;
use crate::qmdl_store::ManifestEntry;
use crate::server::ServerState;
use crate::{battery::BatteryState, qmdl_store::ManifestEntry};
use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use log::error;
use rayhunter::{Device, util::RuntimeMetadata};
use rayhunter::util::RuntimeMetadata;
use serde::Serialize;
use tokio::process::Command;
/// Structure of device system statistics
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct SystemStats {
pub disk_stats: DiskStats,
pub memory_stats: MemoryStats,
pub runtime_metadata: RuntimeMetadata,
#[serde(skip_serializing_if = "Option::is_none")]
pub battery_status: Option<BatteryState>,
}
impl SystemStats {
pub async fn new(qmdl_path: &str, device: &Device) -> Result<Self, String> {
pub async fn new(qmdl_path: &str) -> Result<Self, String> {
Ok(Self {
disk_stats: DiskStats::new(qmdl_path)?,
memory_stats: MemoryStats::new(device).await?,
disk_stats: DiskStats::new(qmdl_path).await?,
memory_stats: MemoryStats::new().await?,
runtime_metadata: RuntimeMetadata::new(),
battery_status: match get_battery_status(device).await {
Ok(status) => Some(status),
Err(RayhunterError::FunctionNotSupportedForDeviceError) => None,
Err(err) => {
log::error!("Failed to get battery status: {err}");
None
}
},
})
}
}
/// Device storage information
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct DiskStats {
/// The partition to which the daemon is installed
partition: String,
/// The total disk size of the partition
total_size: String,
/// Total used size of the partition
used_size: String,
/// Remaining free space of the partition
available_size: String,
/// Disk usage displayed as percentage
used_percent: String,
/// The root folder to which the partition is mounted
mounted_on: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub available_bytes: Option<u64>,
}
impl DiskStats {
#[allow(clippy::unnecessary_cast)] // c_ulong is u32 on ARM, u64 on macOS
pub fn new(qmdl_path: &str) -> Result<Self, String> {
let c_path =
CString::new(qmdl_path).map_err(|e| format!("invalid path {qmdl_path}: {e}"))?;
let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
if unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) } != 0 {
return Err(format!(
"statvfs({qmdl_path}) failed: {}",
std::io::Error::last_os_error()
));
}
let block_size = stat.f_frsize as u64;
let total_kb = (stat.f_blocks as u64 * block_size / 1024) as usize;
let free_kb = (stat.f_bfree as u64 * block_size / 1024) as usize;
let available_kb = (stat.f_bavail as u64 * block_size / 1024) as usize;
let used_kb = total_kb.saturating_sub(free_kb);
let used_percent = format!(
"{}%",
((stat.f_blocks - stat.f_bfree) * 100)
.checked_div(stat.f_blocks)
.unwrap_or(0)
);
// runs "df -h <qmdl_path>" to get storage statistics for the partition containing
// the QMDL file
pub async fn new(qmdl_path: &str) -> Result<Self, String> {
let mut df_cmd = Command::new("df");
df_cmd.arg("-h");
df_cmd.arg(qmdl_path);
let stdout = get_cmd_output(df_cmd).await?;
let mut parts = stdout.split_whitespace().skip(7).to_owned();
Ok(Self {
partition: qmdl_path.to_string(),
total_size: humanize_kb(total_kb),
used_size: humanize_kb(used_kb),
available_size: humanize_kb(available_kb),
used_percent,
mounted_on: qmdl_path.to_string(),
available_bytes: Some(stat.f_bavail as u64 * block_size),
partition: parts.next().ok_or("error parsing df output")?.to_string(),
total_size: parts.next().ok_or("error parsing df output")?.to_string(),
used_size: parts.next().ok_or("error parsing df output")?.to_string(),
available_size: parts.next().ok_or("error parsing df output")?.to_string(),
used_percent: parts.next().ok_or("error parsing df output")?.to_string(),
mounted_on: parts.next().ok_or("error parsing df output")?.to_string(),
})
}
}
/// Device memory information
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct MemoryStats {
/// The total memory available on the device
total: String,
/// The currently used memory
used: String,
/// Remaining free memory
free: String,
}
@@ -130,16 +83,9 @@ async fn get_cmd_output(mut cmd: Command) -> Result<String, String> {
}
impl MemoryStats {
// runs "free -k" and parses the output to retrieve memory stats for most devices,
pub async fn new(device: &Device) -> Result<Self, String> {
// Use busybox for Uz801
let mut free_cmd: Command;
if matches!(device, Device::Uz801) {
free_cmd = Command::new("busybox");
free_cmd.arg("free");
} else {
free_cmd = Command::new("free");
}
// runs "free -k" and parses the output to retrieve memory stats
pub async fn new() -> Result<Self, String> {
let mut free_cmd = Command::new("free");
free_cmd.arg("-k");
let stdout = get_cmd_output(free_cmd).await?;
let mut numbers = stdout
@@ -161,22 +107,11 @@ fn humanize_kb(kb: usize) -> String {
format!("{:.1}M", kb as f64 / 1024.0)
}
#[cfg_attr(feature = "apidocs", utoipa::path(
get,
path = "/api/system-stats",
tag = "Statistics",
responses(
(status = StatusCode::OK, description = "Success", body = SystemStats),
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Error collecting statistics")
),
summary = "Get system info",
description = "Display system/device statistics."
))]
pub async fn get_system_stats(
State(state): State<Arc<ServerState>>,
) -> Result<Json<SystemStats>, (StatusCode, String)> {
let qmdl_store = state.qmdl_store_lock.read().await;
match SystemStats::new(qmdl_store.path.to_str().unwrap(), &state.config.device).await {
match SystemStats::new(qmdl_store.path.to_str().unwrap()).await {
Ok(stats) => Ok(Json(stats)),
Err(err) => {
error!("error getting system stats: {err}");
@@ -188,26 +123,12 @@ pub async fn get_system_stats(
}
}
/// QMDL manifest information
#[derive(Serialize)]
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
pub struct ManifestStats {
/// A vector containing the names of the QMDL files
pub entries: Vec<ManifestEntry>,
/// The currently open QMDL file
pub current_entry: Option<ManifestEntry>,
}
#[cfg_attr(feature = "apidocs", utoipa::path(
get,
path = "/api/qmdl-manifest",
tag = "Statistics",
responses(
(status = StatusCode::OK, description = "Success", body = ManifestStats)
),
summary = "QMDL Manifest",
description = "List QMDL files available on the device and some of their basic statistics."
))]
pub async fn get_qmdl_manifest(
State(state): State<Arc<ServerState>>,
) -> Result<Json<ManifestStats>, (StatusCode, String)> {
@@ -219,20 +140,3 @@ pub async fn get_qmdl_manifest(
current_entry,
}))
}
#[cfg_attr(feature = "apidocs", utoipa::path(
get,
path = "/api/log",
tag = "Statistics",
responses(
(status = StatusCode::OK, description = "Success", content_type = "text/plain"),
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Could not read /data/rayhunter/rayhunter.log file")
),
summary = "Display log",
description = "Download the current device log in UTF-8 plaintext."
))]
pub async fn get_log() -> Result<String, (StatusCode, String)> {
tokio::fs::read_to_string("/data/rayhunter/rayhunter.log")
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
}

View File

@@ -19,3 +19,6 @@ Thumbs.db
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
package-lock.json
yarn.lock

View File

@@ -37,17 +37,6 @@ export default ts.config(
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/naming-convention': [
'error',
{
selector: 'function',
format: ['snake_case'],
},
{
selector: 'method',
format: ['snake_case'],
},
],
},
}
);

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" style="width: 100%">
<body data-sveltekit-preload-data="hover">
<div style="display: contents" class="m-4 xl:m-8">%sveltekit.body%</div>
</body>
</html>

View File

@@ -1,24 +0,0 @@
export class ActionError extends Error {
// The number of this an identical error has happened.
// This is shown as a number next to the error in the UI.
times = $state(1);
constructor(message: string, cause: Error) {
super(message);
this.cause = cause;
}
}
export const action_errors: ActionError[] = $state([]);
export function add_error(e: Error, msg: string): void {
for (const existing of action_errors) {
if (existing.message === msg) {
existing.times += 1;
return;
}
}
const action_error = new ActionError(msg, e);
action_errors.unshift(action_error);
console.log(action_errors.length);
}

View File

@@ -1,7 +1,43 @@
import { describe, it, expect } from 'vitest';
import { AnalysisRowType, parse_finished_report } from './analysis.svelte';
import { AnalysisRowType, EventType, parse_finished_report, Severity } from './analysis.svelte';
import { type NewlineDeliminatedJson } from './ndjson';
const SAMPLE_V1_REPORT_NDJSON: NewlineDeliminatedJson = [
{
analyzers: [
{
name: 'Analyzer 1',
description: 'A first analyzer',
},
{
name: 'Analyzer 2',
description: 'A second analyzer',
},
],
},
{
timestamp: '2024-10-08T13:25:43.011689003-07:00',
skipped_message_reasons: ['The reason why the message was skipped'],
analysis: [],
},
{
timestamp: '2024-10-08T13:25:43.480872496-07:00',
skipped_message_reasons: [],
analysis: [
{
timestamp: '2024-08-19T03:33:54.318Z',
events: [
null,
{
event_type: { type: 'QualitativeWarning', severity: 'Low' },
message: 'Something nasty happened',
},
],
},
],
},
];
const SAMPLE_V2_REPORT_NDJSON: NewlineDeliminatedJson = [
{
analyzers: [
@@ -26,7 +62,7 @@ const SAMPLE_V2_REPORT_NDJSON: NewlineDeliminatedJson = [
events: [
null,
{
event_type: 'Low',
event_type: { type: 'QualitativeWarning', severity: 'Low' },
message: 'Something nasty happened',
},
],
@@ -34,6 +70,40 @@ const SAMPLE_V2_REPORT_NDJSON: NewlineDeliminatedJson = [
];
describe('analysis report parsing', () => {
it('parses v1 example analysis', () => {
const report = parse_finished_report(SAMPLE_V1_REPORT_NDJSON);
expect(report.metadata.report_version).toEqual(1);
expect(report.metadata.analyzers).toEqual([
{
name: 'Analyzer 1',
description: 'A first analyzer',
version: 0,
},
{
name: 'Analyzer 2',
description: 'A second analyzer',
version: 0,
},
]);
expect(report.rows).toHaveLength(2);
expect(report.rows[0].type).toBe(AnalysisRowType.Skipped);
if (report.rows[1].type === AnalysisRowType.Analysis) {
const row = report.rows[1];
expect(row.events).toHaveLength(2);
expect(row.events[0]).toBeNull();
const event = row.events[1];
const expected_timestamp = new Date('2024-08-19T03:33:54.318Z');
expect(row.packet_timestamp.getTime()).toEqual(expected_timestamp.getTime());
if (event !== null && event.type === EventType.Warning) {
expect(event.severity).toEqual(Severity.Low);
} else {
throw 'wrong event type';
}
} else {
throw 'wrong row type';
}
});
it('parses v2 example analysis', () => {
const report = parse_finished_report(SAMPLE_V2_REPORT_NDJSON);
expect(report.metadata.report_version).toEqual(2);
@@ -58,7 +128,11 @@ describe('analysis report parsing', () => {
const event = row.events[1];
const expected_timestamp = new Date('2024-08-19T03:33:54.318Z');
expect(row.packet_timestamp.getTime()).toEqual(expected_timestamp.getTime());
expect(event!.event_type).toEqual('Low');
if (event !== null && event.type === EventType.Warning) {
expect(event.severity).toEqual(Severity.Low);
} else {
throw 'wrong event type';
}
} else {
throw 'wrong row type';
}

View File

@@ -21,7 +21,17 @@ export class ReportMetadata {
constructor(ndjson: any) {
this.analyzers = ndjson.analyzers;
this.rayhunter = ndjson.rayhunter;
this.report_version = ndjson.report_version || 2; // Default to v2
if (ndjson.report_version === undefined) {
this.report_version = 1;
// we consider our legacy (unversioned) heuristics to be v0 --
// this'll let us clearly differentiate some known false-positive
// results from the pre-versioned era from v1 heuristics
this.analyzers.forEach((analyzer) => {
analyzer.version = 0;
});
} else {
this.report_version = ndjson.report_version;
}
}
}
@@ -54,22 +64,77 @@ export type PacketAnalysis = {
events: Event[];
};
export type EventType = 'Informational' | 'Low' | 'Medium' | 'High';
export type Event = {
event_type: EventType;
message: string;
} | null;
function get_event(event_json: any): Event {
if (!['Informational', 'Low', 'Medium', 'High'].includes(event_json.event_type)) {
throw `Invalid/unhandled event type: ${event_json.event_type}`;
}
return event_json;
export type Event = QualitativeWarning | InformationalEvent | null;
export enum EventType {
Informational,
Warning,
}
function get_rows(row_jsons: any[]): AnalysisRow[] {
export type QualitativeWarning = {
type: EventType.Warning;
severity: Severity;
message: string;
};
export enum Severity {
Low,
Medium,
High,
}
export type InformationalEvent = {
type: EventType.Informational;
message: string;
};
function get_event(event_json: any): Event {
if (event_json.event_type.type === 'Informational') {
return {
type: EventType.Informational,
message: event_json.message,
};
} else {
return {
type: EventType.Warning,
severity:
event_json.event_type.severity === 'High'
? Severity.High
: event_json.event_type.severity === 'Medium'
? Severity.Medium
: Severity.Low,
message: event_json.message,
};
}
}
function get_v1_rows(row_jsons: any[]): AnalysisRow[] {
const rows: AnalysisRow[] = [];
for (const row_json of row_jsons) {
for (const reason of row_json.skipped_message_reasons) {
rows.push({
type: AnalysisRowType.Skipped,
reason,
});
}
for (const analysis_json of row_json.analysis) {
const events: Event[] = analysis_json.events.map((event_json: any): Event | null => {
if (event_json === null) {
return null;
} else {
return get_event(event_json);
}
});
rows.push({
type: AnalysisRowType.Analysis,
packet_timestamp: new Date(analysis_json.timestamp),
events,
});
}
}
return rows;
}
function get_v2_rows(row_jsons: any[]): AnalysisRow[] {
const rows: AnalysisRow[] = [];
for (const row_json of row_jsons) {
if (row_json.skipped_message_reason) {
@@ -105,7 +170,7 @@ function get_report_stats(rows: AnalysisRow[]): ReportStatistics {
} else {
for (const event of row.events) {
if (event !== null) {
if (event.event_type === 'Informational') {
if (event.type === EventType.Informational) {
num_informational_logs++;
} else {
num_warnings++;
@@ -123,7 +188,12 @@ function get_report_stats(rows: AnalysisRow[]): ReportStatistics {
export function parse_finished_report(report_json: NewlineDeliminatedJson): AnalysisReport {
const metadata = new ReportMetadata(report_json[0]);
const rows = get_rows(report_json.slice(1));
let rows;
if (metadata.report_version === 1) {
rows = get_v1_rows(report_json.slice(1));
} else {
rows = get_v2_rows(report_json.slice(1));
}
const statistics = get_report_stats(rows);
return {
statistics,

View File

@@ -23,9 +23,11 @@ export type AnalysisResult = {
};
export class AnalysisManager {
public status: Map<string, AnalysisStatus> = $state(new Map());
public reports: Map<string, AnalysisReport | string> = $state(new Map());
public set_queued_status(name: string) {
public status: Map<string, AnalysisStatus> = new Map();
public reports: Map<string, AnalysisReport | string> = new Map();
public async run_analysis(name: string) {
await req('POST', `/api/analysis/${name}`);
this.status.set(name, AnalysisStatus.Queued);
this.reports.delete(name);
}

View File

@@ -1,100 +0,0 @@
<script lang="ts">
import { action_errors } from '../action_errors.svelte';
let pos = $state(0);
let current_error = $derived(action_errors[pos]);
function prev_error() {
if (pos > 0) pos -= 1;
else pos = action_errors.length - 1;
}
function next_error() {
if (pos + 1 < action_errors.length) pos += 1;
else pos = 0;
}
function clear_errors() {
pos = 0;
action_errors.length = 0;
}
</script>
{#if action_errors.length > 0}
<div
class="bg-red-100 border-red-100 drop-shadow p-4 flex flex-col gap-2
border rounded-md flex-1 justify-between fixed z-10 right-3 bottom-3 ml-3"
>
<div class="flex flex-row justify-between">
<span class="text-xl font-bold mb-2 mr-5 flex flex-row items-center gap-1 text-red-600">
<svg
class="w-6 h-6 text-red-600"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
fill-rule="evenodd"
d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm11-4a1 1 0 1 0-2 0v5a1 1 0 1 0 2 0V8Zm-1 7a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H12Z"
clip-rule="evenodd"
/>
</svg>
Error Completing Action {current_error.times > 1 ? `x${current_error.times}` : ''}
</span>
<div class="flex items-center mb-2">
{#if action_errors.length > 1}
<span>{pos + 1}/{action_errors.length}</span>
<button title="previous error" aria-label="previous error" onclick={prev_error}>
<svg
aria-hidden="true"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m 15.499979,19.499979 -6.9999997,-7 6.9999997,-6.9999997"
/>
</svg>
</button>
<button title="next error" aria-label="next error" onclick={next_error}>
<svg
aria-hidden="true"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m 8.5000207,5.4999793 7.0000003,6.9999997 -7.0000003,7"
/>
</svg>
</button>
{/if}
<button title="clear errors" aria-label="clear errors" onclick={clear_errors}>
<svg style="width:24px;height:24px" viewBox="0 0 24 24">
<path
d="M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z"
/>
</svg>
</button>
</div>
</div>
<span>{current_error.message}</span>
{#if current_error.cause}
<details>
<summary>Details</summary>
<code>{current_error.cause}</code>
</details>
{/if}
</div>
{/if}

View File

@@ -35,43 +35,15 @@
return finished && report_available;
});
let button_class = $derived.by(() => {
if (!ready) {
return 'text-gray-700';
} else if ((entry.get_num_warnings() || 0) < 1) {
return 'text-green-700 border-green-500 bg-green-200 text-blue-600 border rounded-full px-2';
} else {
return 'text-red-700 border-red-500 bg-red-200 text-blue-600 border rounded-full px-2';
}
});
let button_class = $derived(ready ? 'text-blue-600 border rounded-full px-2' : '');
</script>
<button class="flex flex-row gap-1 lg:gap-2" disabled={!ready} {onclick}>
<span class="flex flex-row items-center gap-1">
{#if entry.analysis_status === AnalysisStatus.Queued || entry.analysis_status === AnalysisStatus.Running || (entry.analysis_status === AnalysisStatus.Finished && entry.analysis_report === undefined)}
<svg
class="animate-spin h-4 w-4 text-blue-600"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{/if}
<span class={button_class}>{summary}</span>
</span>
<span
class="{button_class} {(entry.get_num_warnings() || 0) < 1
? 'text-green-700 border-green-500 bg-green-200'
: 'text-red-700 border-red-500 bg-red-200'}">{summary}</span
>
<svg
class="w-6 h-6 text-gray-800 transition-transform {analysis_visible ? 'rotate-180' : ''}"
aria-hidden="true"

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { AnalysisRowType, type AnalysisReport } from '$lib/analysis.svelte';
import { AnalysisRowType, EventType, type AnalysisReport } from '$lib/analysis.svelte';
let {
report,
}: {
@@ -11,7 +11,7 @@
dateStyle: 'short',
});
const analyzers = $derived(report.metadata.analyzers);
const analyzers = report.metadata.analyzers;
const skipped_messages: Map<string, number> = $derived.by(() => {
let map = new Map();
@@ -33,71 +33,69 @@
{#if report.statistics.num_warnings === 0 && report.statistics.num_informational_logs === 0}
<p>Nothing to show!</p>
{:else}
<div class="overflow-x-auto">
<table class="table-auto text-left">
<thead class="p-2">
<tr class="bg-gray-300">
<th class="p-2">Timestamp</th>
<th class="p-2">Heuristic</th>
<th class="p-2">Warning</th>
<th class="p-2">Severity</th>
</tr>
</thead>
<tbody>
{#each report.rows as row}
{#if row.type === AnalysisRowType.Analysis}
{@const parsed_date = new Date(row.packet_timestamp)}
{#each row.events as event, analyzerIndex}
{#if event !== null}
{@const analyzer = analyzers[analyzerIndex]}
{@const event_type_class = {
Informational: '',
Low: 'bg-yellow-200',
Medium: 'bg-orange-400',
High: 'bg-red-600',
}[event.event_type]}
<tr class="even:bg-gray-200 odd:bg-white">
<td class="p-2">{date_formatter.format(parsed_date)}</td>
<td class="p-2">{analyzer.name} v{analyzer.version}</td>
<td class="p-2">{event.message}</td>
<td class="p-2 {event_type_class} text-center"
>{event.event_type}</td
>
</tr>
<table class="table-auto text-left">
<thead class="p-2">
<tr class="bg-gray-300">
<th class="p-2">Timestamp</th>
<th class="p-2">Heuristic</th>
<th class="p-2">Warning</th>
<th class="p-2">Severity</th>
</tr>
</thead>
<tbody>
{#each report.rows as row}
{#if row.type === AnalysisRowType.Analysis}
{@const parsed_date = new Date(row.packet_timestamp)}
{#each row.events.filter((e) => e !== null) as event, i}
{@const analyzer = analyzers[i]}
<tr class="even:bg-gray-200 odd:bg-white">
{#if event.type === EventType.Warning}
{@const severity = ['Low', 'Medium', 'High'][event.severity]}
{@const severity_class = [
'bg-red-200',
'bg-red-400',
'bg-red-600',
][event.severity]}
<td class="p-2">{date_formatter.format(parsed_date)}</td>
<td class="p-2">{analyzer.name} v{analyzer.version}</td>
<td class="p-2">{event.message}</td>
<td class="p-2 {severity_class} text-center">{severity}</td>
{:else if event.type === EventType.Informational}
<td class="p-2">{date_formatter.format(parsed_date)}</td>
<td class="p-2">{analyzer.name} v{analyzer.version}</td>
<td class="p-2">{event.message}</td>
<td class="p-2">Info</td>
{/if}
{/each}
{/if}
{/each}
</tbody>
</table>
</div>
</tr>
{/each}
{/if}
{/each}
</tbody>
</table>
{/if}
</div>
{#if report.statistics.num_skipped_packets > 0}
<div>
<p class="text-lg underline">Unparsed Messages</p>
<p>
These are due to a limitation or bug in Rayhunter's parser, and aren't usually a
problem. We'll not accept bug reports about them unless something else is going wrong
(such as false-positives or definite false-negatives)
These are due to a limitation or bug in Rayhunter's parser, and aren't ususally a
problem.
</p>
<div class="overflow-x-auto">
<table class="table-auto text-left">
<thead class="p-2">
<tr class="bg-gray-300">
<th scope="col" class="p-2">Total Msgs Affected</th>
<th scope="col">Reason/Error</th>
<table class="table-auto text-left">
<thead class="p-2">
<tr class="bg-gray-300">
<th scope="col" class="p-2">Total Msgs Affected</th>
<th scope="col">Reason/Error</th>
</tr>
</thead>
<tbody>
{#each skipped_messages.entries() as [message, count]}
<tr class="even:bg-gray-200 odd:bg-white">
<td class="text-center">{count}</td>
<td>{message}</td>
</tr>
</thead>
<tbody>
{#each skipped_messages.entries() as [message, count]}
<tr class="even:bg-gray-200 odd:bg-white">
<td class="text-center">{count}</td>
<td>{message}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/each}
</tbody>
</table>
</div>
{/if}

View File

@@ -1,17 +1,11 @@
<script lang="ts">
import { type ReportMetadata } from '$lib/analysis.svelte';
import type { ManifestEntry } from '$lib/manifest.svelte';
import { AnalysisManager } from '$lib/analysisManager.svelte';
import AnalysisTable from './AnalysisTable.svelte';
import ReAnalyzeButton from './ReAnalyzeButton.svelte';
let {
entry,
manager,
current,
}: {
entry: ManifestEntry;
manager: AnalysisManager;
current: boolean;
} = $props();
</script>
@@ -22,28 +16,7 @@
<p>Error getting analysis report: {entry.analysis_report}</p>
{:else}
{@const metadata: ReportMetadata = entry.analysis_report.metadata}
{@const numWarnings: number = entry.get_num_warnings() || 0}
<div class="flex flex-col gap-2">
{#if !!numWarnings || !current}
<div class="flex flex-row justify-between items-center">
{#if !!numWarnings}
<div
class="text-red-700 border-red-500 border rounded-lg text-blue-600 px-2 py-1 mr-12"
>
Your Rayhunter device raised {`${numWarnings}`} warning{`${
numWarnings > 1 ? 's' : ''
}`}!
<a
href="https://efforg.github.io/rayhunter/faq.html#red"
class="text-blue-600 underline">Read the FAQ</a
> to learn what you can do about it
</div>
{/if}
{#if !current}
<ReAnalyzeButton {entry} {manager} />
{/if}
</div>
{/if}
{#if entry.analysis_report.rows.length > 0}
<AnalysisTable report={entry.analysis_report} />
{:else}

View File

@@ -1,100 +0,0 @@
<script lang="ts">
import { user_action_req } from '$lib/utils.svelte';
let {
url,
method = 'POST',
label,
loadingLabel,
disabled = false,
variant = 'blue',
icon,
onclick,
ariaLabel,
errorMessage,
jsonBody,
}: {
url: string;
method?: string;
label: string;
loadingLabel?: string;
disabled?: boolean;
variant?: 'blue' | 'red' | 'green';
icon?: any; // Svelte snippet
onclick?: () => void | Promise<void>;
ariaLabel?: string;
errorMessage?: string;
jsonBody?: unknown;
} = $props();
let is_requesting = $state(false);
let is_disabled = $derived(disabled || is_requesting);
const variantClasses = {
blue: {
enabled: 'bg-blue-500 hover:bg-blue-700',
disabled: 'bg-blue-500 opacity-50 cursor-not-allowed',
},
red: {
enabled: 'bg-red-500 hover:bg-red-700',
disabled: 'bg-red-500 opacity-50 cursor-not-allowed',
},
green: {
enabled: 'bg-green-500 hover:bg-green-700',
disabled: 'bg-green-500 opacity-50 cursor-not-allowed',
},
};
async function handle_click() {
if (is_disabled) return;
is_requesting = true;
try {
await user_action_req(
method,
url,
errorMessage ? errorMessage : 'Error performing action',
jsonBody
);
if (onclick) {
await onclick();
}
} catch (err) {
console.error(`Failed to ${method} ${url}:`, err);
alert(`Request failed. Please try again.`);
} finally {
is_requesting = false;
}
}
let buttonClasses = $derived(
is_disabled ? variantClasses[variant].disabled : variantClasses[variant].enabled
);
</script>
<button
class="text-white font-bold py-2 px-2 sm:px-4 rounded-md flex flex-row items-center gap-1 {buttonClasses}"
onclick={handle_click}
disabled={is_disabled}
aria-label={ariaLabel || label}
>
<span>{is_requesting && loadingLabel ? loadingLabel : label}</span>
{#if is_requesting}
<svg
class="w-4 h-4 text-white animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{:else if icon}
{@render icon()}
{/if}
</button>

View File

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

View File

@@ -1,19 +1,15 @@
<script lang="ts">
import { get_config, set_config, test_notification, type Config } from '../utils.svelte';
import Modal from './Modal.svelte';
import { get_config, set_config, type Config } from '../utils.svelte';
let { shown = $bindable() }: { shown: boolean } = $props();
let config = $state<Config | null>(null);
let loading = $state(false);
let saving = $state(false);
let testingNotification = $state(false);
let message = $state('');
let messageType = $state<'success' | 'error' | null>(null);
let testMessage = $state('');
let testMessageType = $state<'success' | 'error' | null>(null);
let showConfig = $state(false);
async function load_config() {
async function loadConfig() {
try {
loading = true;
config = await get_config();
@@ -27,7 +23,7 @@
}
}
async function save_config() {
async function saveConfig() {
if (!config) return;
try {
@@ -44,31 +40,32 @@
}
}
async function send_test_notification() {
try {
testingNotification = true;
testMessage = '';
testMessageType = null;
await test_notification();
testMessage = 'Test notification sent successfully!';
testMessageType = 'success';
} catch (error) {
testMessage = `${error}`;
testMessageType = 'error';
} finally {
testingNotification = false;
}
}
// Load config when first shown
$effect(() => {
if (shown && !config) {
load_config();
if (showConfig && !config) {
loadConfig();
}
});
</script>
<Modal bind:shown title="Configuration">
<div class="p-2">
<div class="bg-white rounded-lg shadow-md p-6 m-4">
<button
class="w-full flex justify-between items-center text-xl font-bold mb-4 text-rayhunter-dark-blue hover:text-rayhunter-blue"
onclick={() => (showConfig = !showConfig)}
>
<span>Configuration</span>
<svg
class="w-6 h-6 transition-transform {showConfig ? 'rotate-180' : ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"
></path>
</svg>
</button>
{#if showConfig}
{#if loading}
<div class="text-center py-4">Loading config...</div>
{:else if config}
@@ -76,7 +73,7 @@
class="space-y-4"
onsubmit={(e) => {
e.preventDefault();
save_config();
saveConfig();
}}
>
<div>
@@ -92,12 +89,7 @@
<option value={1}>1 - Subtle mode (colored line)</option>
<option value={2}>2 - Demo mode (orca gif)</option>
<option value={3}>3 - EFF logo</option>
<option value={4}>4 - High visibility (full screen color)</option>
</select>
<p class="text-xs text-gray-500 mt-1">
Note: Rayhunter draws over the device's native UI, so some flickering is
expected
</p>
</div>
<div>
@@ -113,7 +105,8 @@
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
>
<option value={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/stop recording</option
>
</select>
</div>
@@ -132,141 +125,6 @@
</div>
</div>
<div class="border-t pt-4 mt-6 space-y-3">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Notification Settings</h3>
<div>
<label for="ntfy_url" class="block text-sm font-medium text-gray-700 mb-1">
ntfy URL for Sending Notifications (if unset you will not receive
notifications)
</label>
<input
id="ntfy_url"
type="url"
bind:value={config.ntfy_url}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
/>
<p class="text-xs text-gray-500 mt-1">
Test button below uses the saved configuration URL, not the input above
</p>
</div>
<div>
<button
type="button"
onclick={send_test_notification}
disabled={testingNotification}
class="bg-rayhunter-blue hover:bg-rayhunter-dark-blue disabled:opacity-50 disabled:cursor-not-allowed text-white font-bold py-2 px-4 rounded-md flex flex-row gap-1 items-center"
>
{#if testingNotification}
<div
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"
></div>
Sending...
{:else}
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
></path>
</svg>
Send Test Notification
{/if}
</button>
{#if testMessage}
<div
class="mt-2 p-2 rounded text-sm {testMessageType === 'error'
? 'bg-red-100 text-red-700'
: 'bg-green-100 text-green-700'}"
>
{testMessage}
</div>
{/if}
</div>
<div class="space-y-2">
<div class="block text-sm font-medium text-gray-700 mb-1">
Enabled Notification Types
</div>
<div class="flex items-center">
<input
type="checkbox"
id="enable_warning_notifications"
value="Warning"
bind:group={config.enabled_notifications}
/>
<label
for="enable_warning_notifications"
class="ml-2 block text-sm text-gray-700"
>
Warnings
</label>
</div>
<div class="flex items-center">
<input
type="checkbox"
id="enable_lowbattery_notifications"
value="LowBattery"
bind:group={config.enabled_notifications}
/>
<label
for="enable_lowbattery_notifications"
class="ml-2 block text-sm text-gray-700"
>
Low Battery
</label>
</div>
</div>
</div>
<div class="border-t pt-4 mt-6 space-y-3">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Storage Management</h3>
<div>
<label
for="min_space_to_start_recording_mb"
class="block text-sm font-medium text-gray-700 mb-1"
>
Minimum Space to Start Recording (MB)
</label>
<input
id="min_space_to_start_recording_mb"
type="number"
min="1"
bind:value={config.min_space_to_start_recording_mb}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
/>
<p class="text-xs text-gray-500 mt-1">
Recording will not start if less than this amount of disk space is free
</p>
</div>
<div>
<label
for="min_space_to_continue_recording_mb"
class="block text-sm font-medium text-gray-700 mb-1"
>
Minimum Space to Continue Recording (MB)
</label>
<input
id="min_space_to_continue_recording_mb"
type="number"
min="1"
bind:value={config.min_space_to_continue_recording_mb}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
/>
<p class="text-xs text-gray-500 mt-1">
Recording will stop automatically if disk space drops below this level
</p>
</div>
</div>
<div class="border-t pt-4 mt-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">
Analyzer Heuristic Settings
@@ -345,36 +203,10 @@
bind:checked={config.analyzers.incomplete_sib}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
/>
<label for="incomplete_sib" class="ml-2 block text-sm text-gray-700">
<label for="nas_null_cipher" class="ml-2 block text-sm text-gray-700">
Incomplete SIB Heuristic
</label>
</div>
<div class="flex items-center">
<input
id="test_analyzer"
type="checkbox"
bind:checked={config.analyzers.test_analyzer}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
/>
<label for="test_analyzer" class="ml-2 block text-sm text-gray-700">
Test Heuristic (noisy!)
</label>
</div>
<div class="flex items-center">
<input
id="diagnostic_analyzer"
type="checkbox"
bind:checked={config.analyzers.diagnostic_analyzer}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
/>
<label
for="diagnostic_analyzer"
class="ml-2 block text-sm text-gray-700"
>
Diagnostic Analyzer
</label>
</div>
</div>
</div>
@@ -422,5 +254,5 @@
Failed to load configuration. Please try reloading the page.
</div>
{/if}
</div>
</Modal>
{/if}
</div>

View File

@@ -7,6 +7,5 @@
text="Delete ALL Recordings"
prompt={`Are you sure you want to delete ALL recordings?`}
url={`/api/delete-all-recordings`}
name="all recodings"
/>
</div>

View File

@@ -1,27 +1,25 @@
<script lang="ts">
import { user_action_req } from '$lib/utils.svelte';
import { req } from '$lib/utils.svelte';
let {
text,
url,
prompt,
name,
}: {
text?: string;
url: string;
prompt: string;
name: string;
} = $props();
function confirm_delete() {
function confirmDelete() {
if (window.confirm(prompt)) {
user_action_req('POST', url, 'Unable to delete recording ' + name);
req('POST', url);
}
}
</script>
<button
class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-2 sm:px-4 rounded-md flex flex-row"
onclick={confirm_delete}
class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded-md flex flex-row"
onclick={confirmDelete}
aria-label="delete"
>
<p>{text}</p>

View File

@@ -8,16 +8,20 @@
text: string;
full_button?: boolean;
} = $props();
function download() {
window.location.href = url;
}
</script>
<a
href={url}
<button
class="flex flex-row {full_button
? 'bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-2 sm:px-4 rounded-md'
? 'bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-md'
: 'text-blue-600 underline'}"
onclick={download}
>
{text}
<svg class="fill-current w-4 h-4 m-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path d="M13 8V2H7v6H2l8 8 8-8h-5zM0 18h20v2H0v-2z" />
</svg>
</a>
</button>

View File

@@ -1,28 +0,0 @@
<script lang="ts">
import { get_logs } from '$lib/utils.svelte';
import Modal from './Modal.svelte';
let { shown = $bindable() }: { shown: boolean } = $props();
let content: string | undefined = $state(undefined);
$effect(() => {
const interval = setInterval(async () => {
try {
if (content !== undefined && (document.hidden || !shown)) {
return;
}
content = await get_logs();
} catch (error) {
console.log(error);
}
}, 1000);
return () => clearInterval(interval);
});
</script>
<Modal bind:shown title="Logs">
<div class="bg-gray-100 border border-gray-100 rounded-md overflow-scroll">
<pre class="m-2">{content}</pre>
</div>
</Modal>

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { ManifestEntry } from '$lib/manifest.svelte';
import { AnalysisManager } from '$lib/analysisManager.svelte';
import DownloadLink from '$lib/components/DownloadLink.svelte';
import DeleteButton from '$lib/components/DeleteButton.svelte';
import AnalysisStatus from './AnalysisStatus.svelte';
@@ -10,12 +9,10 @@
entry,
current,
server_is_recording,
manager,
}: {
entry: ManifestEntry;
current: boolean;
server_is_recording: boolean;
manager: AnalysisManager;
} = $props();
// passing `undefined` as the locale uses the browser default
@@ -44,7 +41,7 @@
</script>
<div
class="{status_row_color} {status_border_color} drop-shadow p-4 flex flex-col gap-2 border rounded-md flex-1 overflow-x-auto overflow-y-hidden"
class="{status_row_color} {status_border_color} drop-shadow p-4 flex flex-col gap-2 border rounded-md flex-1"
>
{#if current}
<div class="flex flex-row justify-between gap-2">
@@ -81,12 +78,7 @@
'N/A'}</span
>
</div>
{#if entry.stop_reason}
<div class="bg-yellow-50 border border-yellow-300 rounded p-2 text-yellow-800 text-sm">
{entry.stop_reason}
</div>
{/if}
<div class="flex flex-row justify-between lg:justify-end gap-1 mt-2 overflow-x-auto">
<div class="flex flex-row justify-between lg:justify-end gap-2 mt-2">
<DownloadLink url={entry.get_pcap_url()} text="pcap" full_button />
<DownloadLink url={entry.get_qmdl_url()} text="qmdl" full_button />
<DownloadLink url={entry.get_zip_url()} text="zip" full_button />
@@ -96,11 +88,10 @@
<DeleteButton
prompt={`Are you sure you want to delete entry ${entry.name}?`}
url={entry.get_delete_url()}
name={entry.name}
/>
{/if}
</div>
<div class="border-b {analysis_visible ? '' : 'hidden'}">
<AnalysisView {entry} {manager} {current} />
<AnalysisView {entry} />
</div>
</div>

View File

@@ -1,42 +1,38 @@
<script lang="ts">
import { ManifestEntry } from '$lib/manifest.svelte';
import { AnalysisManager } from '$lib/analysisManager.svelte';
import { screenIsLgUp } from '$lib/stores/breakpoint';
import TableRow from './ManifestTableRow.svelte';
import Card from './ManifestCard.svelte';
interface Props {
entries: ManifestEntry[];
server_is_recording: boolean;
manager: AnalysisManager;
}
let { entries, server_is_recording, manager }: Props = $props();
let { entries, server_is_recording }: Props = $props();
</script>
<!--For larger screens we use a table-->
{#if $screenIsLgUp}
<table class="table-auto text-left table">
<thead>
<tr class="bg-gray-100 drop-shadow">
<th class="p-2" scope="col">ID</th>
<th class="p-2" scope="col">Started</th>
<th class="p-2" scope="col">Last Message</th>
<th class="p-2" scope="col">Size</th>
<th class="p-2" scope="col">Download</th>
<th class="p-2" scope="col">Analysis</th>
<th class="p-2" scope="col"></th>
</tr>
</thead>
<tbody>
{#each entries as entry, i}
<TableRow {entry} current={false} {i} {manager} />
{/each}
</tbody>
</table>
{:else}
<!--For smaller screens we use cards-->
<div class="flex flex-col gap-4">
{#each entries as entry}
<Card {entry} current={false} {server_is_recording} {manager} />
<table class="hidden table-auto text-left lg:table">
<thead>
<tr class="bg-gray-100 drop-shadow">
<th class="p-2" scope="col">ID</th>
<th class="p-2" scope="col">Started</th>
<th class="p-2" scope="col">Last Message</th>
<th class="p-2" scope="col">Size</th>
<th class="p-2" scope="col">PCAP</th>
<th class="p-2" scope="col">QMDL</th>
<th class="p-2" scope="col">ZIP</th>
<th class="p-2" scope="col">Analysis</th>
<th class="p-2" scope="col"></th>
</tr>
</thead>
<tbody>
{#each entries as entry, i}
<TableRow {entry} current={false} {i} />
{/each}
</div>
{/if}
</tbody>
</table>
<!--For smaller screens we use cards-->
<div class="lg:hidden flex flex-col gap-4">
{#each entries as entry}
<Card {entry} current={false} {server_is_recording} />
{/each}
</div>

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { ManifestEntry } from '$lib/manifest.svelte';
import { AnalysisManager } from '$lib/analysisManager.svelte';
import DownloadLink from '$lib/components/DownloadLink.svelte';
import DeleteButton from '$lib/components/DeleteButton.svelte';
import AnalysisStatus from './AnalysisStatus.svelte';
@@ -9,12 +8,10 @@
entry,
current,
i,
manager,
}: {
entry: ManifestEntry;
current: boolean;
i: number;
manager: AnalysisManager;
} = $props();
// passing `undefined` as the locale uses the browser default
@@ -43,13 +40,9 @@
>{(entry.last_message_time && date_formatter.format(entry.last_message_time)) || 'N/A'}</td
>
<td class="p-2">{entry.get_readable_qmdl_size()}</td>
<td class="p-2">
<div class="flex flex-row gap-2">
<DownloadLink url={entry.get_pcap_url()} text="pcap" />
<DownloadLink url={entry.get_qmdl_url()} text="qmdl" />
<DownloadLink url={entry.get_zip_url()} text="zip" />
</div>
</td>
<td class="p-2"><DownloadLink url={entry.get_pcap_url()} text="pcap" /></td>
<td class="p-2"><DownloadLink url={entry.get_qmdl_url()} text="qmdl" /></td>
<td class="p-2"><DownloadLink url={entry.get_zip_url()} text="zip" /></td>
<td class="p-2"
><AnalysisStatus onclick={toggle_analysis_visibility} {entry} {analysis_visible} /></td
>
@@ -60,13 +53,12 @@
<DeleteButton
prompt={`Are you sure you want to delete entry ${entry.name}?`}
url={entry.get_delete_url()}
name={entry.name}
/>
</td>
{/if}
</tr>
<tr class="{alternating_row_color} border-b {analysis_visible ? '' : 'hidden'}">
<td class="border-t border-dashed p-2" colspan="9">
<AnalysisView {entry} {manager} {current} />
<AnalysisView {entry} />
</td>
</tr>

View File

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

View File

@@ -1,48 +0,0 @@
<script lang="ts">
import ApiRequestButton from './ApiRequestButton.svelte';
import { AnalysisStatus, AnalysisManager } from '$lib/analysisManager.svelte';
import type { ManifestEntry } from '$lib/manifest.svelte';
let {
entry,
manager,
}: {
entry: ManifestEntry;
manager: AnalysisManager;
} = $props();
let url = $derived(entry.get_reanalyze_url());
let entry_name = $derived(entry.name);
let analysis_status = $derived(entry.analysis_status);
let is_processing = $derived(
analysis_status === AnalysisStatus.Queued || analysis_status === AnalysisStatus.Running
);
async function handle_re_analyze() {
// Update the entry directly for immediate UI feedback
entry.analysis_status = AnalysisStatus.Queued;
entry.analysis_report = undefined;
manager.set_queued_status(entry_name);
}
</script>
<ApiRequestButton
{url}
label="Re-analyze"
loadingLabel="Analyzing..."
disabled={is_processing}
variant="blue"
onclick={handle_re_analyze}
ariaLabel="re-analyze"
errorMessage="Error re-analyzing recoding"
>
{#snippet icon()}
<svg style="width:20px;height:20px" viewBox="0 0 24 24">
<path
fill="white"
d="M12,18A6,6 0 0,1 6,12C6,11 6.25,10.03 6.7,9.2L5.24,7.74C4.46,8.97 4,10.43 4,12A8,8 0 0,0 12,20V23L16,19L12,15M12,4V1L8,5L12,9V6A6,6 0 0,1 18,12C18,13 17.75,13.97 17.3,14.8L18.76,16.26C19.54,15.03 20,13.57 20,12A8,8 0 0,0 12,4Z"
/>
</svg>
{/snippet}
</ApiRequestButton>

View File

@@ -1,60 +1,100 @@
<script lang="ts">
import ApiRequestButton from './ApiRequestButton.svelte';
import { req } from '$lib/utils.svelte';
let {
server_is_recording,
}: {
server_is_recording: boolean;
} = $props();
let client_set_recording = $state(server_is_recording);
let waiting_for_server = $derived(client_set_recording !== server_is_recording);
async function start_recording() {
await req('POST', '/api/start-recording');
client_set_recording = true;
}
async function stop_recording() {
await req('POST', '/api/stop-recording');
client_set_recording = false;
}
const recording_button_classes =
'text-white font-bold py-2 px-4 rounded-md flex flex-row gap-1';
const stop_recording_classes = `${recording_button_classes} bg-red-500 opacity-50 cursor-not-allowed`;
const start_recording_classes = `${recording_button_classes} bg-blue-500 opacity-50 cursor-not-allowed`;
</script>
<div>
{#if server_is_recording}
<ApiRequestButton
url="/api/stop-recording"
label="Stop"
variant="red"
errorMessage="Error stoppping recording"
{#if waiting_for_server}
<button
class={server_is_recording ? stop_recording_classes : start_recording_classes}
disabled
>
{#snippet icon()}
<svg
class="w-6 h-6 text-white"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
<span>{server_is_recording ? 'Stopping...' : 'Starting...'}</span>
<svg
class="w-4 h-4 text-white animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M7 5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H7Z"
/>
</svg>
{/snippet}
</ApiRequestButton>
d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</button>
{:else if server_is_recording}
<button
class="{recording_button_classes} bg-red-500 hover:bg-red-700"
onclick={stop_recording}
>
<span>Stop</span>
<svg
class="w-6 h-6 text-white"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M7 5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H7Z" />
</svg>
</button>
{:else}
<ApiRequestButton
url="/api/start-recording"
label="Start"
variant="blue"
errorMessage="Error starting recording"
<button
class="{recording_button_classes} bg-blue-500 hover:bg-blue-700"
onclick={start_recording}
>
{#snippet icon()}
<svg
class="w-6 h-6 text-white"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
fill-rule="evenodd"
d="M8.6 5.2A1 1 0 0 0 7 6v12a1 1 0 0 0 1.6.8l8-6a1 1 0 0 0 0-1.6l-8-6Z"
clip-rule="evenodd"
/>
</svg>
{/snippet}
</ApiRequestButton>
<span>Start</span>
<svg
class="w-6 h-6 text-white"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
fill-rule="evenodd"
d="M8.6 5.2A1 1 0 0 0 7 6v12a1 1 0 0 0 1.6.8l8-6a1 1 0 0 0 0-1.6l-8-6Z"
clip-rule="evenodd"
/>
</svg>
</button>
{/if}
</div>
<style>
</style>

View File

@@ -7,32 +7,6 @@
} = $props();
const table_cell_classes = 'border p-1 lg:p-2';
let battery_level = $derived(stats.battery_status ? stats.battery_status.level : 0);
let bar_color = $derived.by(() => {
if (stats.battery_status === undefined) {
return '';
}
if (battery_level <= 10) {
return 'fill-red-500';
}
if (battery_level <= 25) {
return 'fill-yellow-300';
}
return 'fill-green-500';
});
let title_text = $derived.by(() => {
if (stats.battery_status === undefined) {
return 'Rayhunter does not yet support displaying the battery level for this device.';
}
let text = `Battery is ${stats.battery_status.level}% full`;
if (stats.battery_status.is_plugged_in) {
text += ' and plugged in';
}
return text;
});
</script>
<div
@@ -58,64 +32,6 @@
Free: {stats.memory_stats.free}, Used: {stats.memory_stats.used}
</td>
</tr>
<tr class="border-b">
<th class={table_cell_classes}> Battery </th>
<td class={table_cell_classes}>
<svg
width="80"
height="30"
viewBox="0 0 80 30"
role="img"
xmlns="http://www.w3.org/2000/svg"
class="battery-icon"
>
<title>{title_text}</title>
<!-- Battery body -->
<rect
class="fill-none stroke-neutral-800 stroke-2"
width="70"
height="30"
rx="3"
ry="3"
/>
<!-- Battery terminal -->
<rect
class="fill-neutral-800"
x="70"
y="7"
width="8"
height="16"
rx="2"
ry="2"
/>
<!-- Battery charge bar -->
<rect
class={bar_color}
x="2"
y="2"
height="26"
rx="2"
ry="2"
style="width: {battery_level * 0.66}px;"
/>
{#if stats.battery_status && stats.battery_status.is_plugged_in}
<!-- Lightning bolt icon -->
<path
class="fill-yellow-300 stroke-neutral-800 stroke-1"
d="M38 3 L28 17 L34 17 L30 27 L40 13 L34 13 Z"
/>
{/if}
{#if !stats.battery_status}
<!-- Question mark icon -->
<text
class="fill-neutral-500 text-[20px] font-bold [text-anchor:middle] [dominant-baseline:central]"
x="35"
y="15">?</text
>
{/if}
</svg>
</td>
</tr>
</tbody>
</table>
</div>

View File

@@ -11,7 +11,6 @@ interface JsonManifestEntry {
start_time: string;
last_message_time: string;
qmdl_size_bytes: number;
stop_reason: string | null;
}
export class Manifest {
@@ -58,7 +57,6 @@ export class ManifestEntry {
public analysis_size_bytes = $state(0);
public analysis_status: AnalysisStatus | undefined = $state(undefined);
public analysis_report: AnalysisReport | string | undefined = $state(undefined);
public stop_reason: string | undefined = $state(undefined);
constructor(json: JsonManifestEntry) {
this.name = json.name;
@@ -67,9 +65,6 @@ export class ManifestEntry {
if (json.last_message_time) {
this.last_message_time = new Date(json.last_message_time);
}
if (json.stop_reason) {
this.stop_reason = json.stop_reason;
}
}
get_readable_qmdl_size(): string {
@@ -107,8 +102,4 @@ export class ManifestEntry {
get_delete_url(): string {
return `/api/delete-recording/${this.name}`;
}
get_reanalyze_url(): string {
return `/api/analysis/${this.name}`;
}
}

View File

@@ -1,29 +0,0 @@
// stores/breakpoint.ts
import { readable, type Readable } from 'svelte/store';
import { breakpoints } from '../../theme';
type Breakpoint = keyof typeof breakpoints;
// Store that tracks if a specific breakpoint matches
export function create_breakpoint_store(breakpoint: Breakpoint): Readable<boolean> {
return readable<boolean>(false, (set) => {
const width = breakpoints[breakpoint];
const mediaQuery = window.matchMedia(`(min-width: ${width})`);
// Set initial value
set(mediaQuery.matches);
// Update on change
const handler = (e: MediaQueryListEvent) => set(e.matches);
mediaQuery.addEventListener('change', handler);
// Cleanup
return () => mediaQuery.removeEventListener('change', handler);
});
}
// Create stores for each breakpoint
export const screenIsSmUp: Readable<boolean> = create_breakpoint_store('sm');
export const screenIsMdUp: Readable<boolean> = create_breakpoint_store('md');
export const screenIsLgUp: Readable<boolean> = create_breakpoint_store('lg');
export const screenIsXlUp: Readable<boolean> = create_breakpoint_store('xl');

View File

@@ -2,7 +2,6 @@ export interface SystemStats {
disk_stats: DiskStats;
memory_stats: MemoryStats;
runtime_metadata: RuntimeMetadata;
battery_status?: BatteryStatus;
}
export interface RuntimeMetadata {
@@ -18,7 +17,6 @@ export interface DiskStats {
available_size: string;
used_percent: string;
mounted_on: string;
available_bytes?: number;
}
export interface MemoryStats {
@@ -26,8 +24,3 @@ export interface MemoryStats {
used: string;
free: string;
}
export interface BatteryStatus {
level: number;
is_plugged_in: boolean;
}

View File

@@ -1,4 +1,3 @@
import { add_error } from './action_errors.svelte';
import { Manifest } from './manifest.svelte';
import type { SystemStats } from './systemStats';
@@ -9,55 +8,24 @@ export interface AnalyzerConfig {
null_cipher: boolean;
nas_null_cipher: boolean;
incomplete_sib: boolean;
test_analyzer: boolean;
diagnostic_analyzer: boolean;
}
export enum enabled_notifications {
Warning = 'Warning',
LowBattery = 'LowBattery',
}
export interface Config {
ui_level: number;
colorblind_mode: boolean;
key_input_mode: number;
ntfy_url: string;
enabled_notifications: enabled_notifications[];
analyzers: AnalyzerConfig;
min_space_to_start_recording_mb: number;
min_space_to_continue_recording_mb: number;
}
export async function req(method: string, url: string, json_body?: unknown): Promise<string> {
const options: RequestInit = { method };
if (json_body !== undefined) {
options.body = JSON.stringify(json_body);
options.headers = { 'Content-Type': 'application/json' };
}
const response = await fetch(url, options);
const responseBody = await response.text();
export async function req(method: string, url: string): Promise<string> {
const response = await fetch(url, {
method: method,
});
const body = await response.text();
if (response.status >= 200 && response.status < 300) {
return responseBody;
return body;
} else {
throw new Error(responseBody);
}
}
// A wrapper around req that reports errors to the UI
export async function user_action_req(
method: string,
url: string,
error_msg: string,
json_body?: unknown
): Promise<string | undefined> {
try {
return await req(method, url, json_body);
} catch (error) {
if (error instanceof Error) {
add_error(error, error_msg);
}
return undefined;
throw new Error(body);
}
}
@@ -70,10 +38,6 @@ export async function get_system_stats(): Promise<SystemStats> {
return JSON.parse(await req('GET', '/api/system-stats'));
}
export async function get_logs(): Promise<string> {
return await req('GET', '/api/log');
}
export async function get_config(): Promise<Config> {
return JSON.parse(await req('GET', '/api/config'));
}
@@ -92,24 +56,3 @@ export async function set_config(config: Config): Promise<void> {
throw new Error(error);
}
}
export async function test_notification(): Promise<void> {
const response = await fetch('/api/test-notification', {
method: 'POST',
});
if (!response.ok) {
const error = await response.text();
throw new Error(error);
}
}
export interface TimeResponse {
system_time: string;
adjusted_time: string;
offset_seconds: number;
}
export async function get_daemon_time(): Promise<TimeResponse> {
return JSON.parse(await req('GET', '/api/time'));
}

View File

@@ -7,132 +7,34 @@
import { AnalysisManager } from '$lib/analysisManager.svelte';
import SystemStatsTable from '$lib/components/SystemStatsTable.svelte';
import DeleteAllButton from '$lib/components/DeleteAllButton.svelte';
import RecordingControls from '$lib/components/RecordingControls.svelte';
import RecordingControls from '$lib/components//RecordingControls.svelte';
import ConfigForm from '$lib/components/ConfigForm.svelte';
import ActionErrors from '$lib/components/ActionErrors.svelte';
import ClockDriftAlert from '$lib/components/ClockDriftAlert.svelte';
import LogView from '$lib/components/LogView.svelte';
let manager: AnalysisManager = new AnalysisManager();
let loaded = $state(false);
let filter_threshold: boolean = $state(false);
let entries: ManifestEntry[] = $state([]);
let current_entry: ManifestEntry | undefined = $state(undefined);
let system_stats: SystemStats | undefined = $state(undefined);
let update_error: string | undefined = $state(undefined);
let logview_shown: boolean = $state(false);
let config_shown: boolean = $state(false);
$effect(() => {
const interval = setInterval(async () => {
try {
// Don't update UI if browser tab isn't visible
if (document.hidden) {
return;
}
await manager.update();
let new_manifest = await get_manifest();
await new_manifest.set_analysis_status(manager);
entries = new_manifest.entries;
current_entry = new_manifest.current_entry;
await manager.update();
let new_manifest = await get_manifest();
await new_manifest.set_analysis_status(manager);
entries = filter_threshold
? new_manifest.entries.filter((e) => e.get_num_warnings())
: new_manifest.entries;
current_entry = new_manifest.current_entry;
system_stats = await get_system_stats();
update_error = undefined;
loaded = true;
} catch (error) {
if (error instanceof Error) {
update_error = error.message;
} else {
update_error = '';
}
}
system_stats = await get_system_stats();
loaded = true;
}, 1000);
return () => clearInterval(interval);
});
</script>
<LogView bind:shown={logview_shown} />
<ConfigForm bind:shown={config_shown} />
<div class="p-4 xl:px-8 bg-rayhunter-blue drop-shadow flex flex-row justify-between items-center">
<!-- https://www.w3.org/WAI/tutorials/images/decorative/ -->
<img src="/rayhunter_text.png" alt="" class="h-10 xl:h-12" />
<div class="flex flex-row gap-4">
<button onclick={() => (logview_shown = true)} class="flex flex-row gap-1 group">
<span class="hidden text-white group-hover:text-gray-400 lg:flex">Logs</span>
<svg
class="w-6 h-6 text-white group-hover:text-gray-400"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M10 14H3"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
<path
d="M10 18H3"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
<path
d="M14 15L17.5 18L21 15"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M3 6L13.5 6M20 6L17.75 6"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
<path
d="M20 10L9.5 10M3 10H5.25"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg>
</button>
<button onclick={() => (config_shown = true)} class="flex flex-row gap-1 group">
<span class="hidden text-white group-hover:text-gray-400 lg:flex">Config</span>
<svg
class="w-6 h-6 text-white group-hover:text-gray-400"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 13v-2a1 1 0 0 0-1-1h-.757l-.707-1.707.535-.536a1 1 0 0 0 0-1.414l-1.414-1.414a1 1 0 0 0-1.414 0l-.536.535L14 5.757V5a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v.757L8.293 6.464l-.536-.535a1 1 0 0 0-1.414 0L4.929 7.343a1 1 0 0 0 0 1.414l.535.536L4.757 11H4a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h.757l.707 1.707-.535.536a1 1 0 0 0 0 1.414l1.414 1.414a1 1 0 0 0 1.414 0l.536-.535L10 18.243V19a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-.757l1.707-.707.536.535a1 1 0 0 0 1.414 0l1.414-1.414a1 1 0 0 0 0-1.414l-.535-.536.707-1.707H20a1 1 0 0 0 1-1Z"
/>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z"
/>
</svg>
</button>
<div class="w-px bg-white/30 self-stretch"></div>
<a
class="flex flex-row gap-1 group"
href="https://github.com/EFForg/rayhunter/issues"
@@ -179,74 +81,13 @@
/>
</svg>
</a>
<a
class="flex flex-row gap-1 group"
href="https://supporters.eff.org/donate"
target="_blank"
>
<span class="hidden text-white group-hover:text-gray-400 lg:flex">Donate</span>
<svg
class="w-6 h-6 text-white group-hover:text-gray-400"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="m12.75 20.66 6.184-7.098c2.677-2.884 2.559-6.506.754-8.705-.898-1.095-2.206-1.816-3.72-1.855-1.293-.034-2.652.43-3.963 1.537-1.31-1.108-2.67-1.571-3.962-1.537-1.515.04-2.823.76-3.72 1.855-1.806 2.2-1.924 5.821.753 8.705l6.184 7.098.245.281a.75.75 0 0 0 1.09 0l.246-.281Z"
/>
</svg>
</a>
</div>
</div>
<div class="m-4 xl:mx-8 flex flex-col gap-4">
{#if update_error !== undefined}
<div
class="bg-red-100 border-red-100 drop-shadow p-4 flex flex-col gap-2 border rounded-md flex-1 justify-between"
>
<span class="text-2xl font-bold mb-2 flex flex-row items-center gap-2 text-red-600">
<svg
class="w-8 h-8 text-red-600"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
fill-rule="evenodd"
d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm11-4a1 1 0 1 0-2 0v5a1 1 0 1 0 2 0V8Zm-1 7a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H12Z"
clip-rule="evenodd"
/>
</svg>
Connection Error
</span>
<span
>This webpage is not currently receiving updates from your Rayhunter device. This
could be due to loss of connection or some issue with your device.</span
>
{#if update_error}
<details>
<summary>Error</summary>
<code>{update_error}</code>
</details>
{/if}
</div>
{/if}
<ActionErrors />
<ClockDriftAlert />
{#if loaded}
<div class="flex flex-col lg:flex-row gap-4">
{#if current_entry}
<Card
entry={current_entry}
current={true}
server_is_recording={!!current_entry}
{manager}
/>
<Card entry={current_entry} current={true} server_is_recording={!!current_entry} />
{:else}
<div
class="bg-red-100 border-red-100 drop-shadow p-4 flex flex-col gap-2 border rounded-md flex-1 justify-between"
@@ -271,9 +112,9 @@
</svg>
WARNING: Not Running
</span>
<span>
Rayhunter is not currently running and will not detect abnormal behavior!
</span>
<span
>Rayhunter is not currently running and will not detect abnormal behavior!</span
>
<div class="flex flex-row justify-end mt-2">
<RecordingControls server_is_recording={!!current_entry} />
</div>
@@ -282,26 +123,11 @@
<SystemStatsTable stats={system_stats!} />
</div>
<div class="flex flex-col gap-2">
<div class="flex flex-row gap-2">
<div class="text-xl flex-1">History</div>
<div class="flex flex-row items-center gap-2 px-3">
<label
for="filter_threshold"
class="block text-md font-medium text-gray-700 mb-1"
>
Filter for Warnings
</label>
<input
type="checkbox"
id="filter_threshold"
bind:checked={filter_threshold}
class="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
/>
</div>
</div>
<ManifestTable {entries} server_is_recording={!!current_entry} {manager} />
<span class="text-xl">History</span>
<ManifestTable {entries} server_is_recording={!!current_entry} />
</div>
<DeleteAllButton />
<ConfigForm />
{:else}
<div class="flex flex-col justify-center items-center">
<!-- https://www.w3.org/WAI/tutorials/images/decorative/ -->

View File

@@ -1,11 +0,0 @@
/** These are the default Tailwind CSS breakpoints.
* We're defining them here so they can be referenced
* programmatically in other parts of the application.
*/
export const breakpoints = {
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
'2xl': '1536px',
} as const;

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

View File

@@ -1,5 +1,4 @@
import type { Config } from 'tailwindcss';
import { breakpoints } from './src/theme';
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
@@ -11,7 +10,6 @@ export default {
'rayhunter-dark-blue': '#3f3da0',
'rayhunter-green': '#94ea18',
},
screens: breakpoints,
},
},

View File

@@ -5,7 +5,7 @@ export default defineConfig({
server: {
proxy: {
'/api': {
target: process.env.API_TARGET || 'http://localhost:8080',
target: 'http://localhost:8080',
changeOrigin: true,
secure: false,
configure: (proxy, _options) => {

18
dist/config.toml.in vendored
View File

@@ -12,7 +12,6 @@ colorblind_mode = false
# 1 = Subtle mode, display a colored line at the top of the screen when rayhunter is running (green=running, white=paused, red=warnings)
# 2 = Demo Mode, display a fun orca gif
# 3 = display the EFF logo
# 4 = High Visibility mode, fill the entire screen with the status color (green=running, white=paused, red=warnings)
#
# TP-Link with one-bit display:
# 0 = invisible mode
@@ -20,20 +19,9 @@ colorblind_mode = false
ui_level = 1
# 0 = rayhunter does not read button presses
# 1 = double-tapping the power button starts new recording
# 1 = double-tapping the power button starts/stops recordings
key_input_mode = 0
# If set, attempts to send a notification to the url when a new warning is triggered
ntfy_url = ""
# What notification types to enable. Does nothing if the above ntfy_url is not set.
enabled_notifications = ["Warning", "LowBattery"]
# Disk Space Management
# Minimum free space (MB) required to start recording
min_space_to_start_recording_mb = 1
# Minimum free space (MB) to continue recording (stops if below this)
min_space_to_continue_recording_mb = 1
# Analyzer Configuration
# Enable/disable specific IMSI catcher detection heuristics
# See https://github.com/EFForg/rayhunter/blob/main/doc/heuristics.md for details
@@ -41,8 +29,6 @@ min_space_to_continue_recording_mb = 1
imsi_requested = true
connection_redirect_2g_downgrade = true
lte_sib6_and_7_downgrade = true
null_cipher = true
null_cipher = true
nas_null_cipher = true
incomplete_sib = true
test_analyzer = false
diagnostic_analyzer = true

View File

@@ -1,26 +1,22 @@
# Summary
- [Introduction](./introduction.md)
- [Support, feedback, and community](./support-feedback-community.md)
- [Frequently Asked Questions](./faq.md)
[Introduction](./introduction.md)
- [Installation](./installation.md)
- [Installing from the latest release](./installing-from-release.md)
- [Installing from the latest release (Windows)](./installing-from-release-windows.md)
- [Installing from source](./installing-from-source.md)
- [Updating Rayhunter](./updating-rayhunter.md)
- [Configuration](./configuration.md)
- [Uninstalling](./uninstalling.md)
- [Using Rayhunter](./using-rayhunter.md)
- [Rayhunter's heuristics](./heuristics.md)
- [Re-analyzing recordings](./reanalyzing.md)
- [How we analyze a capture](./analyzing-a-capture.md)
- [Supported devices](./supported-devices.md)
- [Porting to new devices](./porting.md)
- [Orbic/Kajeet RC400L](./orbic.md)
- [Orbic RC400L](./orbic.md)
- [TP-Link M7350](./tplink-m7350.md)
- [TP-Link M7310](./tplink-m7310.md)
- [Tmobile TMOHS1](./tmobile-tmohs1.md)
- [UZ801](./uz801.md)
- [Wingtech CT2MHS01](./wingtech-ct2mhs01.md)
- [PinePhone and PinePhone Pro](./pinephone.md)
- [Moxee Hotspot](./moxee.md)
- [REST API Documentation](./api-docs.md)
- [Support, feedback, and community](./support-feedback-community.md)
- [Frequently Asked Questions](./faq.md)

View File

@@ -1,3 +1,3 @@
# How we analyze a capture
Teams of highly trained squirrels. Video coming soon!
TODO

View File

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

View File

@@ -7,18 +7,13 @@ Rayhunter can be configured through web user interface or by editing `/data/rayh
Through web UI you can set:
- **Device UI Level**, which defines what Rayhunter shows on device's built-in screen. *Device UI Level* could be:
- *Invisible mode*: Rayhunter does not show anything on the built-in screen
- *Subtle mode (colored line)*: Rayhunter shows green line if there are no warnings, red line if there are warnings (warnings could be checked through web UI) and white line if Rayhunter is not recording.
- *Demo mode (orca gif)*, which shows image of orcas *and* colored line.
- *EFF logo*, which shows EFF logo *and* colored line.
- *High visibility (full screen color)*: fills the entire screen with the status color (green for recording, red for warnings, white for paused).
- **Device Input Mode**, which defines behavior of built-in power button of the device. *Device Input Mode* could be:
- *Disable button control*: built-in power button of the device is not used by Rayhunter.
- *Double-tap power button to start new recording*: double clicking on a built-in power button of the device stops and immediately restarts the recording. This could be useful if Rayhunter's heuristics is triggered and you get the red line, and you want to "reset" the past warnings. Normally you can do that through web UI, but sometimes it is easier to double tap on power button.
- *Subtle mode (colored line)*: Rayhunter shows green line if there are no warnings, red line if there are warnings (warnings could be checked through web UI) and white line if Rayhunter is not recording
- *Demo mode (orca gif)*, which shows image of orca fish *and* colored line
- *EFF logo*, which shows EFF logo and *and* colored line.
- **Device Input Mode**, which defines behaviour of built-in power button of the device. *Device Input Mode* could be:
- *Disable button control*: built-in power button of the device is not used by Rayhunter;
- *Double-tap power button to start/stop recording*: double clicking on a built-in power button of the device stops and immediatelly restarts the recording. This could be useful if Rayhunter's heuristichs is triggered and you get the red line, and you want to "reset" the past warnings. Normally you can do that through web UI, but sometimes it is easier to double tap on power button.
- **Colorblind Mode** enables color blind mode (blue line is shown instead of green line, red line remains red). Please note that this does not cover all types of color blindness, but switching green to blue should be about enough to differentiate the color change for most types of color blindness.
- **ntfy URL**, which allows setting a [ntfy](https://ntfy.sh/) URL to which notifications of new detections will be sent. The topic should be unique to your device, e.g., `https://ntfy.sh/rayhunter_notifications_ba9di7ie` or `https://myserver.example.com/rayhunter_notifications_ba9di7ie`. The ntfy Android and iOS apps can then be used to receive notifications. More information can be found in the [ntfy docs](https://docs.ntfy.sh/).
- **Enabled Notification Types** allows enabling or disabling the following types of notifications:
- *Warnings*, which will alert when a heuristic is triggered. Alerts will be sent at most once every five minutes.
- *Low Battery*, which will alert when the device's battery is low. Notifications may not be supported for all devices—you can check if your device is supported by looking at whether the battery level indicator is functioning on the System Information section of the Rayhunter UI.
- With **Analyzer Heuristic Settings** you can switch on or off built-in [Rayhunter heuristics](heuristics.md). Some heuristics are experimental or can trigger a lot of false positive warnings in some networks (our tests have shown that some heuristics have different behavior in US or European networks). In that case you can decide whether you would like to have the heuristics that trigger a lot of false positives on or off. Please note that we are constantly improving and adding new heuristics, so a new release may reduce false positives in existing heuristics as well.
- With **Analyzer Heuristic Settings** you can switch on or off built-in [Rayhunter heuristics](heuristics.md). Some heuristics are experimental or can trigger a lot of false positive warnings in some networks (our tests have shown that some heuristics have different behaviour in US or European networks). In that case you can decide whether you would like to have the heuristics that trigger a lot of false positives on or off. Please note that we are constantly improving and adding new heuristics, so new release may reduce false positives in existing heuristics as well.
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 a GitHub](https://github.com/EFForg/rayhunter/blob/main/dist/config.toml.in).

View File

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

View File

@@ -2,49 +2,38 @@
### Do I need an active SIM card to use Rayhunter?
**It Depends**. Operation of Rayhunter does require the insertion of a SIM card into the device, but that sim card does not have to be actively registered with a service plan. If you want to use the device as a hotspot in addition to a research device, or get [notifications](./configuration.md), an active plan would of course be necessary.
### How can I test that my device is working?
You can enable the `Test Heuristic` under `Analyzer Heuristic Settings` in the config section on your web dashboard. This will cause an alert to trigger every time your device sees a cell tower, you might need to reboot your device or move around a bit to get this one to trigger, but it will be very noisy once it does. People have also tested it by building IMSI catchers at home, but we don't recommend that, since it violates FCC regulations and will probably upset your neighbors.
**It Depends**. Operation of Rayhunter does require the insertion of a SIM card into the device, but whether that SIM card has to be currently active for our tests to work is still under investigation. If you want to use the device as a hotspot in addition to a research device an active plan would of course be necessary, however we have not done enough testing yet to know whether an active subscription is required for detection. If you want to test the device with an inactive SIM card, we would certainly be interested in seeing any data you collect, and especially any runs that trigger an alert!
<a name="red"></a>
### Help, Rayhunter's line is red/orange/yellow/dotted/dashed! What should I do?
### Help, Rayhunter's line is red! What should I do?
Unfortunately, the circumstances that might lead to a positive cell site simulator (CSS) signal are quite varied, so we don't have a universal recommendation for how to deal with the a positive signal. Depending on your circumstances and threat model, you may want to turn off your phone until you are out of the area and tell your friends to do the same!
Unfortunately, the circumstances that might lead to a positive cell site simulator (CSS) signal are quite varied, so we don't have a universal recommendation for how to deal with the a positive signal. Depending on your circumstances and threat model, you may want to turn off your phone until you are out of the area (or put it on airplane mode) and tell your friends to do the same!
If you've received a Rayhunter warning and would like to help us with our research, please send your Rayhunter data captures (Zip file downloaded from the web interface) to us at our [Signal](https://signal.org/) username [**ElectronicFrontierFoundation.90**](https://signal.me/#eu/HZbPPED5LyMkbTxJsG2PtWc2TXxPUR1OxBMcJGLOPeeCDGPuaTpOi5cfGRY6RrGf) with the following information: capture date, capture location, device, device model, and Rayhunter version. If you're unfamiliar with Signal, feel free to check out our [Security Self Defense guide on it](https://ssd.eff.org/module/how-to-use-signal).
If you've received a Rayhunter warning and would like to help us with our research, please send your Rayhunter data captures (QMDL and PCAP logs) to us at our [Signal](https://signal.org/) username [**ElectronicFrontierFoundation.90**](https://signal.me/#eu/HZbPPED5LyMkbTxJsG2PtWc2TXxPUR1OxBMcJGLOPeeCDGPuaTpOi5cfGRY6RrGf) with the following information: capture date, capture location, device, device model, and Rayhunter version. If you're unfamiliar with Signal, feel free to check out our [Security Self Defense guide on it](https://ssd.eff.org/module/how-to-use-signal).
Please note that this file may contain sensitive information such as your IMSI and the unique IDs of cell towers you were near which could be used to ascertain your location at the time.
### Should I get a locked or unlocked orbic device? What is the difference?
If you want to use a non-Verizon SIM card you will probably need an unlocked device. But it's not clear which devices are locked nor how to unlock them, we welcome any experimentation and information regarding the use of unlocked devices. So far most verizon branded orbic devices we have encountered are actually unlocked.
If you want to use a non-Verizon SIM card you will probably need an unlocked device. But it's not clear how locked the locked devices are nor how to unlock them, we welcome any experimentation and information regarding the use of unlocked devices.
### How do I re-enable USB tethering after installing Rayhunter?
If you have installed with `./installer orbic-usb`, you might find that USB
tethering is now disabled. If you have run `./installer orbic`, this section is not
relevant as it does not use or touch USB.
[First obtain a shell](./orbic.md#shell), then:
Make sure USB tethering is also enabled in the Orbic's UI, and then run the following commands:
```sh
# inside of Orbic's shell:
echo 9 > /usrdata/mode.cfg
reboot
installer util shell "echo 9 > /usrdata/mode.cfg"
installer util shell reboot
```
Make sure USB tethering is also enabled in the Orbic's UI.
To disable tethering again:
```sh
# inside of Orbic's shell:
echo 3 > /usrdata/mode.cfg
reboot
installer util shell "echo 3 > /usrdata/mode.cfg"
installer util shell reboot
```
See `/data/usb/boot_hsusb_composition` for a list of USB modes and Android USB gadget settings.
@@ -52,16 +41,16 @@ See `/data/usb/boot_hsusb_composition` for a list of USB modes and Android USB g
### How do I disable the WiFi hotspot on the Orbic RC400L?
To disable both WiFi bands, [first obtain a shell](./orbic.md#shell), then:
To disable both WiFi bands:
```sh
# inside of Orbic's shell:
sed -i 's/<wlan><Feature><state>1<\/state>/<wlan><Feature><state>0<\/state>/g' /usrdata/data/usr/wlan/wlan_conf_6174.xml && reboot
adb shell
/bin/rootshell -c "sed -i 's/<wlan><Feature><state>1<\/state>/<wlan><Feature><state>0<\/state>/g' /usrdata/data/usr/wlan/wlan_conf_6174.xml && reboot"
```
To re-enable WiFi:
```sh
# inside of Orbic's shell:
sed -i 's/<wlan><Feature><state>0<\/state>/<wlan><Feature><state>1<\/state>/g' /usrdata/data/usr/wlan/wlan_conf_6174.xml && reboot
adb shell
/bin/rootshell -c "sed -i 's/<wlan><Feature><state>0<\/state>/<wlan><Feature><state>1<\/state>/g' /usrdata/data/usr/wlan/wlan_conf_6174.xml && reboot"
```

View File

@@ -4,78 +4,9 @@ Rayhunter includes several analyzers to detect potential IMSI catcher activity.
## Available Analyzers
### IMSI Requested (v3)
This analyzer tests whether the eNodeB sends an IMSI or IMEI Identity Request NAS message under suspicious .
Mobile networks primarily request IMSI or IMEI from a mobile device during initial network attachment or when the network cannot identify the mobile device by its temporary identification (TMSI - *Temporary Mobile Subscriber Identity* or GUTI - *Globally Unique Temporary Identifier* in 4G/5G terminology).
IMSI request therefore usually happens when you first turn the device on especially after it has been off for a long time. Another possibility is, that you reboot your mobile device and your temporary ID expired. Sometimes temporary identification can expire if you have been in an area where there is absolutely no connection to your service provider or after you left your device on an airplane mode and then reconnect to the network (especially being disconnected for a long time). IMSI could also be requested when you connect to a new network (for instance for roaming), when you swap she SIM card or when your device moves to a new *Tracking Area* or *Location Area* and the network can not map the temporary identification to your device. IMSI number can also be requested after core network reboot.
It should also be noted that the network periodically reassigns your device new temporary identification to enhance security and avoid tracking, but in that cases usually does not request IMSI.
During these events the phone will typically go on to authenticate that the network is legitimate and then establish service with the network it is connected to.
What we consider suspicious is the following chain of events:
* Phone connects to a new tower.
* Tower asks for phones identity (IMEI or IMSI.)
* Authentication does *NOT* happen.
* Tower requests phone to disconnect.
Looking for this chain of events is much less prone to false positives than naively looking for any time the IMSI/IMEI is sent. We do still sometimes get false positives when users are in an airplane that is coming in for a landing however. This is likely due to having been disconnected for a while and then being over towers that are not able to route to your home network, but we are still researching.
This is the attack used by commercial IMSI catchers used by law enforcement.
This heuristic will also alert you if any of the following happen:
* Identity is requested after authentication.
* Identity is requested without your phone connecting to the tower.
* Identity is requested and then authentication doesn't happen shortly thereafter.
This heuristic will also issue a notification every time your identity is sent to the network under non suspicious circumstances. This is for diagnostic purposes.
### Connection Release/Redirected Carrier 2G Downgrade
This analyzer tests if a base station releases your device's connection and redirects your device to a 2G base station. This heuristic is useful, because some IMSI catchers may operate in a such way that they downgrade connection to 2G where they can intercept the communication (by performing man-in-the-middle attack).
### LTE SIB6/7 Downgrade (v2)
This analyzer tests if LTE base station is broadcasting a SIB type 6 and 7 messages which include 2G/3G frequencies with higher priorities.
SIB (*System Information Block*) Type 6 and 7 are specific types of broadcast messages sent by the base station (eNodeB in 4G networks) to mobile devices. They contain essential radio-related configuration parameters to help mobile device perform cell reselection.
This attack exploits the fact that SIB broadcast messages are not encrypted or authenticated. This allows them to pretend to be a legitimate cell by broadcasting fake system information in order to force mobile devices to downgrade from more secure 4G (LTE) to less secure 2G (GSM) network and then steal IMSI and/or perform man-in-the-middle attack. That is why this is also called a downgrade attack.
SIB6 is used for cell reselection to CDMA2000 systems which are not supported by many modern mobile phones, and SIB7 Provides the mobile device with information to perform cell reselection to GSM/EDGE networks. Therefore SIB6 messages are quite rare, while malformed SIB7 messages are much more frequent in practice.
This heuristic is useful even in countries where 2g is still prevalent. A well behaved tower should always advertise its other 4g neighbors at a higher priority than 2g/3g neighbors. (Older versions of this heuristic were prone to false positives.)
### Null Cipher
This analyzer tests whether the cell suggests using a null cipher (EEA0) in the RRC layer. That means that encryption between your mobile device and base station is turned off.
Normally this should never happen, because null cipher is used almost exclusively for testing and debugging in labs or in controlled environments. Sometimes null cipher is used if encryption negotiation fails or isnt supported (however in most networks this should not be the case). Also, some regulations allow unencrypted communications in **specific** emergency cases.
The general rule is that null cipher should never be used in commercial deployments, except in very controlled conditions (e.g., test labs) or in a very specific regulatory-approved use cases.
On the other hand, IMSI catchers often use null cipher to avoid setting up secure contexts (because they lack valid keys) and/or to trick mobile device into using unencrypted links (which makes eavesdropping easier).
### NAS Null Cipher
This analyzer tests whether the security mode command at the NAS layer suggests using a null cipher (EEA0). This would usually only happen after a mobile device has successfully authenticated with the MME (*Mobility Management Entity* - core network component that handles signaling and control) but still it shouldn't happen at all. This could be indicative of an attack though using SS7 (*Signaling System 7* - a set of telecommunication protocols used to set up and manage calls and other services) to get key material from the HLR (*Home Location Register* - a database in mobile telecommunications networks that stores subscriber information) of the mobile phone for a successful authentication.
It could also indicate an IMSI catcher which is connected to the mobile network MME and HLR through cooperation between government and telecom provider. Or it could be a false positive if the telecom provider is intending to use null ciphers (if encryption is illegal in some country, or they have some misconfiguration of the network), however this should be very rare case.
### Incomplete SIB
This analyzer tests whether the SIB1 message contains a complete SIB chain (SIB3, SIB5, etc.). A legitimate SIB1 message should contain timing information for at least 2 additional SIBs (SIB3, 4, and 5 being the most common) but a fake base station will often not bother to send additional SIBs beyond 1 and 2 (i. e. some IMSI catchers send just SIB1 and *one additional* SIB).
On its own this might just be a misconfigured base station (though we have only seen it in the wild under suspicious circumstances) but combined with other heuristics such as **IMSI Requested** detection it should be considered as a strong indicator of malicious activity.
### Diagnostic Information
This analyzer displays some diagnostic information about when your device connects and disconnects from certain towers. It is helpful for analysis of suspicious PCAPs. The informational warnings in here can safely be ignored until there is a low, medium, or high severity warning.
### Test Analyzer
This analyzer is great for testing if your Rayhunter installation works. It will alert every time a new tower is seen (specifically every time a tower broadcasts a SIB1 message.) It is designed to be very noisy so we do not recommend leaving it on but if this alerts it means your Rayhunter device is working!
- **IMSI Requested**: Tests whether the eNodeB sends an IMSI Identity Request NAS message. This can sometimes happen under normal circumstances when the network doesn't already have a TMSI (Temporary Mobile Subscriber ID or GUTI in 5G terminology) for your device. This most often happens when you first turn the device on, especially after it has been off for a long time or if you are in an area where there is absolutely no connection to your service provider. This can also happen if you leave your device on while on an airplane and it suddenly connects to a new tower after being disconnected for a long time. However, if you get this warning at a time when you have been steadily connected to towers and the device has been on for a while it can be treated as suspcious.
- **Connection Release/Redirected Carrier 2G Downgrade**: Tests if a cell releases our connection and redirects us to a 2G cell. This heuristic mostly makes sense in the US or other countries where there are no more operating 2G base stations. In countries where 2G is still in service (such as most of EU), this heuristics may trigger a lot of false positives, so you may want to disable it. However it should be noted that many IMSI Catchers operate in a such way that they downgrade connection to 2G and also that this heuristics has been vastly improved to reduce false positive warnings. See [Wikipedia page on past 2G networks](https://en.wikipedia.org/wiki/2G#Past_2G_networks) for information about your country.
- **LTE SIB6/7 Downgrade**: Tests for LTE cells broadcasting a SIB type 6 and 7 messages which include 2G/3G frequencies with higher priorities.
- **Null Cipher**: Tests whether the cell suggests using a null cipher (EEA0) in the RRC layer (that means that encryption between your mobile device and base staation is turned off).
- **NAS Null Cipher**: Tests whether the security mode command at the NAS layer suggests using a null cipher (EEA0). This would usually only happen after a UE has successfully authenticated with the MME but still it shouldn't happen at all. This could be indicative of an attack though using SS7 to get key material from the HLR of the UE for a succesful authentication. It could also indicate an IMSI catcher which is connected to the mobile network MME and HLR through cooperation between government and telecom provider. Or it could be a false positive if the telecom provider is intending to use null ciphers (if encryption is illegal or they have some misconfiguration of the network), however this should be very rare case.
- **Incomplete SIB**: Tests whether the SIB1 message contains a complete SIB chain (SIB3, SIB5, etc.) A legitimate SIB1 mesage should contain timing information for at least 2 additional sibs (sib3, 4, and 5 being the most common) but a fake base station will often not bother to send additional SIBs beyond 1 and 2. On its own this might just be a misconfigured base station (though we have only seen it in the wild under suspicious circumstances) but combined with other heuristics such as **ISMI Requested** detection it should be considered a strong indicator of malicious activity.

View File

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

View File

@@ -0,0 +1,32 @@
# Installing from the latest release (Windows)
Windows support in Rayhunter's installer is a work-in-progress. Depending on the device, the installation instructions differ.
## TP-Link
1. Connect the device via WiFi or USB Tethering -- you should be able to view the TP-Link admin page on <http://192.168.0.1>.
2. Download the latest release (must be at least 0.3.0) for windows-x86_64, and unpack the zipfile.
3. Open PowerShell or CMD in that extracted folder, the installer: `./installer tplink`
4. Follow the instructions on the screen, if there are any.
## Orbic
1. Connect the device to your computer using the provided USB cable.
1. Install the [Zadig WinUSB driver installer](https://zadig.akeo.ie/).
1. Open Zadig, click options->show all devices
![Zadig](./zadig2.png)
1. Select 'RNDIS (Interface 0)'
![Zadig](./zadig.png)
1. Click 'install driver' and wait for it to finish.
2. Download the latest `rayhunter-vX.X.X-windows-x86_64.zip` from the [Rayhunter releases page](https://github.com/EFForg/rayhunter/releases). The version you download will have numbers instead of X
3. Unzip `rayhunter-vX.X.X-windows-x86_64` .
1. Open a powershell terminal by pressing Win+R and typing `powershell` and hitting enter.
5. Type `cd ~\Downloads\rayhunter-v<x.x.x>-windows-x86_64` (**Replace <x.x.x> with the Rayhunter version you just unzipped**) and hit enter.
5. Run the install script: `.\installer.exe orbic` and hit enter.
- The device will restart multiple times over the next few minutes.
- You will know it is done when you see terminal output that says `checking for rayhunter server...success!`
6. Rayhunter should now be running! You can verify this by following the instructions below to [view the web UI](./using-rayhunter.md#the-web-ui). You should also see a green line flash along the top of top the display on the device.

View File

@@ -2,8 +2,7 @@
Make sure you've got one of Rayhunter's [supported devices](./supported-devices.md). These instructions have only been tested on macOS and Ubuntu 24.04. If they fail, you will need to [install Rayhunter from source](./installing-from-source.md).
1. **For the TP-Link only,** insert a FAT-formatted SD card. This will be used to store all recordings.
2. Download the latest `rayhunter-vX.X.X-PLATFORM.zip` from the [Rayhunter releases page](https://github.com/EFForg/rayhunter/releases) for your platform:
1. Download the latest `rayhunter-vX.X.X-PLATFORM.zip` from the [Rayhunter releases page](https://github.com/EFForg/rayhunter/releases) for your platform:
- for Linux on x64 architecture: `linux-x64`
- for Linux on ARM64 architecture: `linux-aarch64`
- for Linux on armv7/v8 (32-bit) architecture: `linux-armv7`
@@ -11,68 +10,39 @@ Make sure you've got one of Rayhunter's [supported devices](./supported-devices.
- for MacOS on ARM (M1/M2 etc.) architecture: `macos-arm`
- for Windows: `windows-x86_64`
3. Decompress the `rayhunter-vX.X.X-PLATFORM.zip` archive. Open the terminal and navigate to the folder. (Be sure to replace X.X.X with the correct version number!)
2. Decompress the `rayhunter-vX.X.X-PLATFORM.zip` archive. Open the terminal and navigate to the folder. (Be sure to replace X.X.X with the correct version number!)
```bash
unzip ~/Downloads/rayhunter-vX.X.X-PLATFORM.zip
cd ~/Downloads/rayhunter-vX.X.X-PLATFORM
```
On Windows you can decompress using the file browser, then navigate to the
folder that contains `installer.exe`, **hold Shift**, Right-Click inside the
folder, then click "Open in PowerShell".
3. Turn on your device by holding the power button on the front.
4. **Connect to your device.**
* For the Orbic, connect the device using a USB-C cable.
* For TP-Link, connect to its network using either WiFi or USB Tethering.
First turn on your device by holding the power button on the front.
4. Run the installer:
Then connect to the device using either WiFi or USB tethering.
```bash
# On MacOS, you must first remove the quarantine bit
xattr -d com.apple.quarantine installer
```
Then run the installer:
```bash
./installer orbic
# or: ./installer tplink
# or: ./installer wingtech
```
You know you are in the right network when you can access
<http://192.168.1.1> (Orbic) or <http://192.168.0.1> (TP-Link) and see the
hardware's own admin menu.
The device will restart multiple times over the next few minutes.
5. **On MacOS only**, you have to run `xattr -d
com.apple.quarantine installer` to allow execution of
the binary.
You will know it is done when you see terminal output that says `Testing Rayhunter... done`
6. **Run the installer.**
```bash
# For Orbic:
./installer orbic --admin-password 'mypassword'
# Note: the arguments --admin-username 'myusername' and --admin-ip 'mydeviceip'
# may be required if different from the default.
# Or install over USB if you want ADB and a root shell (not recommended for most users)
./installer orbic-usb
# For TP-Link:
./installer tplink
```
* On Verizon Orbic, the password is the one used to login to the device's admin menu, and the default is the WiFi password.
* ***Note:*** If you have changed the device username, password, or IP address from their default values, these must be provided as arguments to the installer command above.
* On Kajeet/Smartspot devices, the default password is `$m@rt$p0tc0nf!g`
* On Moxee-brand devices, check under the battery for the password.
* You can reset the password by pressing the button under the back case until the unit restarts.
TP-Link does not require an `--admin-password` parameter.
For other devices, check `./installer --help` or the
respective page in the sidebar under "Supported
Devices."
7. The installer will eventually tell you it's done, and the device will reboot.
8. Rayhunter should now be running! You can verify this by [viewing Rayhunter's web UI](./using-rayhunter.md). You should also see a green line flash along the top of top the display on the device.
5. Rayhunter should now be running! You can verify this by [viewing Rayhunter's web UI](./using-rayhunter.md). You should also see a green line flash along the top of top the display on the device.
## Troubleshooting
* If you are having trouble installing Rayhunter and you're connecting to your device over USB, try using a different USB cable to connect the device to your computer. If you are using a USB hub, try using a different one or directly connecting the device to a USB port on your computer. A faulty USB connection can cause the Rayhunter installer to fail.
* You can test your device by enabling the test heuristic. This will be very noisy and fire an alert every time you see a new tower. Be sure to turn it off when you are done testing.
* On MacOS if you encounter an error that says "No Orbic device found," it may because you have the "Allow accessories to connect" security setting set to "Ask for approval." You may need to temporarily change it to "Always" for the script to run. Make sure to change it back to a more secure setting when you're done.
```bash

View File

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

View File

@@ -2,14 +2,11 @@
<img style="display: block; margin: 0 auto" alt="Rayhunter Logo - An Orca taking a bite out of a cellular signal bar" src="https://www.eff.org/files/styles/media_browser_preview/public/banner_library/rayhunter-banner.png" />
Rayhunter is a project for detecting IMSI catchers, also known as cell-site simulators or stingrays. It was first designed to run on a cheap mobile hotspot called the Orbic RC400L, but thanks to community efforts can [support some other devices as well](./supported-devices.md).
Rayhunter is a project for detecting IMSI catchers, also known as cell-site simulators or stingrays. It's designed to run on a cheap mobile hotspot called the Orbic RC400L, but thanks to community efforts can [support some other devices as well](./supported-devices.md).
It's also designed to be as easy to install and use as possible, regardless of your level of technical skills. This guide should provide you all you need to acquire a compatible device, install Rayhunter, and start catching IMSI catchers.
&rarr; Check out the [installation guide](./installation.md) to get started.
&rarr; To learn more about the aim of the project, and about IMSI catchers in general, please check out our [introductory blog post](https://www.eff.org/deeplinks/2025/03/meet-rayhunter-new-open-source-tool-eff-detect-cellular-spying).
&rarr; For discussion, help, or to join the mattermost channel and get involved with the project and community check out the [many ways listed here](./support-feedback-community.md)!
To learn more about the aim of the project, and about IMSI catchers in general, please check out our [introductory blog post](https://www.eff.org/deeplinks/2025/03/meet-rayhunter-new-open-source-tool-eff-detect-cellular-spying). Otherwise, check out the [installation guide](./installation.md) to get started.
**LEGAL DISCLAIMER:** Use this program at your own risk. We believe running this program does not currently violate any laws or regulations in the United States. However, we are not responsible for civil or criminal liability resulting from the use of this software. If you are located outside of the US please consult with an attorney in your country to help you assess the legal risks of running this program.

View File

@@ -1,47 +0,0 @@
# KonnectONE Moxee Hotspot (K779HSDL)
Supported in Rayhunter since version 0.6.0.
The Moxee Hotspot is a device very similar to the Orbic RC400L. It seems to be
primarily for the US market.
**These devices have relatively little storage. The Orbic is usually a better alternative, though might be more expensive.**
- [KonnectONE product page](https://www.konnectone.com/specs-hotspot)
- [Moxee product page](https://www.moxee.com/hotspot)
## Supported bands
According to [FCC ID 2APQU-K779HSDL](https://fcc.report/FCC-ID/2APQU-K779HSDL), the device supports the following LTE bands:
| Band | Frequency |
|------|-------------------------|
| 2 | 1900 MHz (PCS) |
| 4 | 1700/2100 MHz (AWS-1) |
| 5 | 850 MHz (CLR) |
| 12 | 700 MHz (Lower SMH) |
| 13 | 700 MHz (Upper SMH) |
| 25 | 1900 MHz (Extended PCS) |
| 26 | 850 MHz (Extended) |
| 41 | 2500 MHz (TDD) |
| 66 | 1700/2100 MHz (E-AWS) |
| 71 | 600 MHz |
## Installation
Connect to the hotspot's network using WiFi or USB tethering and run:
```sh
./installer moxee --admin-password 'mypassword'
```
The password (in place of `mypassword`) is under the battery.
`./installer moxee` is almost the same as `./installer orbic`, it just comes
with slightly better defaults that will give you more space for recordings.
## Obtaining a shell
```sh
./installer util orbic-shell
```

View File

@@ -1,13 +1,10 @@
# Orbic/Kajeet RC400L
# Orbic RC400L
The Orbic RC400L is an inexpensive LTE modem primarily designed for the US market, and the original device for which Rayhunter is developed.
It is also sometimes sold under the brand Kajeet RC400L. This is the exact same hardware and can be treated the same.
You can buy an Orbic [using bezos
bucks](https://www.amazon.com/Orbic-Verizon-Hotspot-Connect-Enabled/dp/B08N3CHC4Y),
or on [eBay](https://www.ebay.com/sch/i.html?_nkw=orbic+rc400l). You should not
pay more than 30 USD for such a device (without shipping).
or on [eBay](https://www.ebay.com/sch/i.html?_nkw=orbic+rc400l).
[Please check whether the Orbic works in your country](https://www.frequencycheck.com/countries/), and whether the Orbic RC400L supports the right frequency bands for your purpose before buying.
@@ -22,28 +19,8 @@ pay more than 30 USD for such a device (without shipping).
| Wifi 5Ghz | a/ac/ax |
| Wifi 6 | 🮱 |
## Two kinds of installers
The orbic's installation routine underwent many different changes:
1. The ADB-based shellscript prior to version 0.3.0
2. The Rust-based, ADB-based installer since version 0.3.0
3. Then, starting with 0.6.0, an alternative installer `./installer
orbic-network` that is supposed to work more reliably, can run over the
Orbic's WiFi connection and without the need to manually install USB drivers
on Windows.
4. Starting with 0.8.0, `orbic-network` has been renamed to `orbic`, and the
old `./installer orbic` is now called `./installer orbic-usb`.
It's possible that many tutorials out there still refer to some of the old
installation routines.
<a name=shell></a>
## Obtaining a shell
After running the installer, there will not be a rootshell and ADB will not be
enabled. Instead you can use `./installer util orbic-shell`.
If you are using an installer prior to 0.7.0 or `orbic-usb` explicitly, you can
obtain a root shell by running `adb shell` or `./installer util shell`. Then,
inside of that shell you can run `/bin/rootshell` to obtain "fakeroot."
After running through the installation procedure, you can obtain a root shell
by running `adb shell` or `./installer util shell`. Then, inside of that shell
you can run `/bin/rootshell` to obtain "fakeroot."

View File

@@ -35,14 +35,12 @@ The modem is fully capable of running Rayhunter, but lacks both a screen and a n
Note that the Quectel EG25-G does not support LTE band 48 (CBRS 3500MHz), used in the US for unlicensed 4G/5G connectivity.
## Installing
Download and extract the installer *on a shell on the PinePhone itself*. Unlike other Rayhunter installers, this has to be run on the device itself. Then run:
```sh
./installer pinephone
```
## Accessing Rayhunter
Because the modem does not have its own display or network interface, Rayhunter is only accessible on the pinephone by forwarding tcp over adb.
## Accessing rayhunter
Because the modem does not have its own display or network interface, rayhunter is only accessible on the pinephone by forwarding tcp over adb.
```sh
adb forward tcp:8080 tcp:8080

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