Compare commits
324 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2b722ad5f | ||
|
|
5e2058e7ac | ||
|
|
60daf4b716 | ||
|
|
4df317b028 | ||
|
|
d7fb8b9c85 | ||
|
|
d399532494 | ||
|
|
45df91a364 | ||
|
|
672ed8c6c6 | ||
|
|
5c7c7cd766 | ||
|
|
f41a8d38fe | ||
|
|
f9c8c4671e | ||
|
|
723b20541e | ||
|
|
272a4aeabf | ||
|
|
6ae70556ba | ||
|
|
2915dea9e9 | ||
|
|
6941bc57b6 | ||
|
|
5b9dd856a8 | ||
|
|
5007cb0b36 | ||
|
|
1b244122df | ||
|
|
3c4cb56ce6 | ||
|
|
58843413b5 | ||
|
|
4ee504fed7 | ||
|
|
894af5da0d | ||
|
|
d810e8e3c0 | ||
|
|
8755d5694c | ||
|
|
70a7d81d05 | ||
|
|
c182543dfa | ||
|
|
056f4c02e5 | ||
|
|
237983a8cb | ||
|
|
9967f93af2 | ||
|
|
3358a06454 | ||
|
|
382702a9ee | ||
|
|
67c3eb7d91 | ||
|
|
98b05bfdb0 | ||
|
|
01d10b87b3 | ||
|
|
410e902848 | ||
|
|
f03f9fcdae | ||
|
|
4b68c30ed3 | ||
|
|
b5481331c2 | ||
|
|
ace65a8e55 | ||
|
|
920044a5b2 | ||
|
|
6cb9a195ed | ||
|
|
90e2bddbbb | ||
|
|
3fa583f671 | ||
|
|
8e6b86b26f | ||
|
|
d40d4fb9c1 | ||
|
|
a12bc4075e | ||
|
|
51327917b0 | ||
|
|
4982463b57 | ||
|
|
68aafd41e1 | ||
|
|
8b053a9ef8 | ||
|
|
ace325a38a | ||
|
|
6d02731a81 | ||
|
|
69b7fecb17 | ||
|
|
279169257d | ||
|
|
9a60e3f820 | ||
|
|
77e51ec2f6 | ||
|
|
c9c92706bc | ||
|
|
643fa9f979 | ||
|
|
96a02763e4 | ||
|
|
ff421de127 | ||
|
|
635c8a0188 | ||
|
|
5b8a0ef8d4 | ||
|
|
757b053a33 | ||
|
|
5d9bc27ac9 | ||
|
|
7d45be4f0c | ||
|
|
d58881c1f5 | ||
|
|
4e16c7f9ce | ||
|
|
c6d0cccb76 | ||
|
|
f2d32512aa | ||
|
|
e463d40c07 | ||
|
|
c8edacf1ed | ||
|
|
ce8260b92c | ||
|
|
d6e4f6a71d | ||
|
|
a2269fb5f7 | ||
|
|
1c4e9b8499 | ||
|
|
fce30a78a2 | ||
|
|
6a16ad7f15 | ||
|
|
ec5bd81a70 | ||
|
|
fbce9c8b04 | ||
|
|
92b825a9e3 | ||
|
|
c285e2ca08 | ||
|
|
4a7452806d | ||
|
|
2e85d4f186 | ||
|
|
e3acfe9144 | ||
|
|
7418cc19b3 | ||
|
|
cc72f1eabc | ||
|
|
e071bc6619 | ||
|
|
60015e0ff6 | ||
|
|
bbcf23899e | ||
|
|
c97212cdc8 | ||
|
|
894f457751 | ||
|
|
da34c05364 | ||
|
|
30d62b8d7b | ||
|
|
1f7b7f0f1a | ||
|
|
da53ec9df2 | ||
|
|
0beff5ea63 | ||
|
|
a946ebbe92 | ||
|
|
64a87534ee | ||
|
|
4a94545498 | ||
|
|
9e532ac975 | ||
|
|
35e3c80313 | ||
|
|
221c3591fd | ||
|
|
cf0061fe53 | ||
|
|
5bd2909c0d | ||
|
|
3e1eb9d5e6 | ||
|
|
adfe081eaf | ||
|
|
f165dddd0c | ||
|
|
214375ead2 | ||
|
|
0d4514a332 | ||
|
|
5180205144 | ||
|
|
5ed1a9bae3 | ||
|
|
abc3c07201 | ||
|
|
98ee6dacf8 | ||
|
|
a9f1284fa6 | ||
|
|
d31bf45f95 | ||
|
|
8e8a28ae26 | ||
|
|
a7a5221c90 | ||
|
|
469a716b7c | ||
|
|
c569101c36 | ||
|
|
b9945827c4 | ||
|
|
f97bc56f2c | ||
|
|
55ba316046 | ||
|
|
5ae6f0c5ce | ||
|
|
7e1b410f89 | ||
|
|
32b67df55d | ||
|
|
a8087c6840 | ||
|
|
f2028a704f | ||
|
|
e04b78f0e0 | ||
|
|
ece589331f | ||
|
|
b95ff90e5e | ||
|
|
33745bc4e2 | ||
|
|
73682240d6 | ||
|
|
43324c0ad7 | ||
|
|
f559e10d44 | ||
|
|
f28022920a | ||
|
|
63b07b83f5 | ||
|
|
934e0d70d8 | ||
|
|
769826dcea | ||
|
|
e4bfa7a1f3 | ||
|
|
d95da9b382 | ||
|
|
941ea59e11 | ||
|
|
8082e013f4 | ||
|
|
f72194ab3e | ||
|
|
3b1547c749 | ||
|
|
af17788a36 | ||
|
|
1a8010964e | ||
|
|
a7ce1ad4d3 | ||
|
|
531e9aa6fb | ||
|
|
833d0e41b4 | ||
|
|
056cdac546 | ||
|
|
6ea2b0a4e6 | ||
|
|
d3f70fee01 | ||
|
|
2ee4ab5082 | ||
|
|
7708efd0c9 | ||
|
|
6b15f807df | ||
|
|
0a1f9f4de1 | ||
|
|
fb1d550793 | ||
|
|
2fc0144905 | ||
|
|
fb1657676e | ||
|
|
bb5c288c2f | ||
|
|
d63f419fbc | ||
|
|
a33c7511eb | ||
|
|
1cc5eb4c4c | ||
|
|
c4b2c3bbe2 | ||
|
|
d9c58129ff | ||
|
|
41d3b4ed39 | ||
|
|
4113b71baf | ||
|
|
4f0bc3ad93 | ||
|
|
cf2d406d88 | ||
|
|
057c9acb40 | ||
|
|
57b0455363 | ||
|
|
fa96520fe5 | ||
|
|
a269a45244 | ||
|
|
99676f1590 | ||
|
|
9fe75ac961 | ||
|
|
151e186ef9 | ||
|
|
06c4dd468e | ||
|
|
740f979293 | ||
|
|
700258b0f2 | ||
|
|
f661e2e318 | ||
|
|
b12a159f0a | ||
|
|
4e40994577 | ||
|
|
1b29cf0dee | ||
|
|
aafd83d636 | ||
|
|
dd67fbf645 | ||
|
|
e440dab736 | ||
|
|
30e543898b | ||
|
|
01e762a3d6 | ||
|
|
fa9e9319c2 | ||
|
|
b317200307 | ||
|
|
55f78cf749 | ||
|
|
cb9e8254a8 | ||
|
|
a9afa347f0 | ||
|
|
75944a7d16 | ||
|
|
e11bb2518e | ||
|
|
31076ec8b2 | ||
|
|
5e22b5c6a8 | ||
|
|
3dc373f0d3 | ||
|
|
bccdcf36e1 | ||
|
|
fb9c4ab85b | ||
|
|
e864ce0a51 | ||
|
|
7f990ae4bd | ||
|
|
3ac4acd83c | ||
|
|
5c5333f0c7 | ||
|
|
60934e593b | ||
|
|
4099eb30a5 | ||
|
|
f81adad897 | ||
|
|
775468f037 | ||
|
|
91e825adff | ||
|
|
499b86aca6 | ||
|
|
7b897c335d | ||
|
|
c47be1074b | ||
|
|
326d4106bd | ||
|
|
df8a1f5606 | ||
|
|
b0f5296c20 | ||
|
|
4e792b1402 | ||
|
|
9144259202 | ||
|
|
58f0071864 | ||
|
|
3c0716c877 | ||
|
|
bf8f1fb8eb | ||
|
|
2a808245fb | ||
|
|
208ccbafaa | ||
|
|
b150f9dc4f | ||
|
|
b6ef48e0f6 | ||
|
|
fddb18546c | ||
|
|
2911838b1c | ||
|
|
adbe3991dd | ||
|
|
fbc47187c5 | ||
|
|
5f601a209e | ||
|
|
04652d2097 | ||
|
|
034e0632e4 | ||
|
|
4edf001ca4 | ||
|
|
b41f61bfa6 | ||
|
|
46a5bf8a84 | ||
|
|
2ee45382fc | ||
|
|
f507cc0269 | ||
|
|
0780b527b9 | ||
|
|
b0a1b14160 | ||
|
|
b7243dae62 | ||
|
|
0c4a0123aa | ||
|
|
9bc8a7892b | ||
|
|
431a97ca65 | ||
|
|
0364bfbc98 | ||
|
|
996e47684c | ||
|
|
266f2b2e53 | ||
|
|
2080cd7845 | ||
|
|
9af8e006b0 | ||
|
|
e841e22774 | ||
|
|
0d9f53f602 | ||
|
|
c9dcbbe5d6 | ||
|
|
61d6ff6510 | ||
|
|
e79dc4a8f0 | ||
|
|
6204bc0195 | ||
|
|
65b9843e39 | ||
|
|
d0d01089dd | ||
|
|
9c26e89b24 | ||
|
|
1f4786db19 | ||
|
|
88f81d86fa | ||
|
|
0b3c0de481 | ||
|
|
188e9f436b | ||
|
|
f2b5aa2743 | ||
|
|
b785a7f21c | ||
|
|
09d35ccec7 | ||
|
|
5ae186bc73 | ||
|
|
c765a40426 | ||
|
|
93cfbea361 | ||
|
|
8e6bed97b7 | ||
|
|
4214b27c0f | ||
|
|
f69487853a | ||
|
|
7eb61748d7 | ||
|
|
ca4e560e92 | ||
|
|
2ffb1d4620 | ||
|
|
77944dd17c | ||
|
|
50301076f0 | ||
|
|
21c839678b | ||
|
|
332a7ffbd0 | ||
|
|
8d250553b7 | ||
|
|
fa897e73fa | ||
|
|
c3494e338f | ||
|
|
f9b2cd6a59 | ||
|
|
eb072fb38c | ||
|
|
91f82fc71d | ||
|
|
6fda8450dc | ||
|
|
bbfe5877fe | ||
|
|
75d3740f66 | ||
|
|
94c576fd96 | ||
|
|
ee83613757 | ||
|
|
840f8ad8b0 | ||
|
|
c9ac834ca7 | ||
|
|
8629aacf6b | ||
|
|
a3fd1479f9 | ||
|
|
049c563f02 | ||
|
|
a33b5a3418 | ||
|
|
107ba58296 | ||
|
|
d016279172 | ||
|
|
5a084f1abb | ||
|
|
3619df32ab | ||
|
|
34d87d1fd7 | ||
|
|
da4952e70f | ||
|
|
30323b8329 | ||
|
|
28b0f409db | ||
|
|
12640cc878 | ||
|
|
26eda5904f | ||
|
|
3e26e61b05 | ||
|
|
565c0f1e67 | ||
|
|
6bd36921d8 | ||
|
|
c83ae30be8 | ||
|
|
fa612241a5 | ||
|
|
10592bbd9d | ||
|
|
327eaddcd7 | ||
|
|
32149c3b37 | ||
|
|
e47d4dacc4 | ||
|
|
4009e3d1ed | ||
|
|
b2cd735a07 | ||
|
|
94e9a88a91 | ||
|
|
f4a6c834d2 | ||
|
|
95e8f846d3 | ||
|
|
15f128add1 | ||
|
|
87f9cc403b | ||
|
|
7addf3a67f | ||
|
|
4d8cc9b738 | ||
|
|
b0d797d206 | ||
|
|
1ae3b5020b |
@@ -1,3 +1,41 @@
|
||||
[target.aarch64-apple-darwin]
|
||||
linker = "rust-lld"
|
||||
rustflags = ["-C", "target-feature=+crt-static"]
|
||||
|
||||
[target.aarch64-unknown-linux-musl]
|
||||
linker = "rust-lld"
|
||||
rustflags = ["-C", "target-feature=+crt-static"]
|
||||
|
||||
# apt install build-essential libc6-armhf-cross libc6-dev-armhf-cross gcc-arm-linux-gnueabihf
|
||||
[target.armv7-unknown-linux-gnueabihf]
|
||||
linker = "arm-linux-gnueabihf-gcc"
|
||||
rustflags = ["-C", "target-feature=+crt-static"]
|
||||
|
||||
[target.armv7-unknown-linux-musleabihf]
|
||||
linker = "rust-lld"
|
||||
rustflags = ["-C", "target-feature=+crt-static"]
|
||||
|
||||
# Disable rust-lld for x86 macOS because the linker crashers when compiling
|
||||
# the installer in release mode with debug info on.
|
||||
# [target.x86_64-apple-darwin]
|
||||
# linker = "rust-lld"
|
||||
# rustflags = ["-C", "target-feature=+crt-static"]
|
||||
|
||||
[target.x86_64-unknown-linux-musl]
|
||||
linker = "rust-lld"
|
||||
rustflags = ["-C", "target-feature=+crt-static"]
|
||||
|
||||
# keep line numbers in stack traces for non-firmware binaries
|
||||
[profile.release]
|
||||
debug = "limited"
|
||||
|
||||
# optimizations to reduce the binary size of firmware binaries
|
||||
[profile.firmware]
|
||||
inherits = "release"
|
||||
strip = true
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
debug = false
|
||||
|
||||
|
||||
1
.git-blame-ignore-revs
Normal file
@@ -0,0 +1 @@
|
||||
c5bbaabe15d4ccfee97b9997a13569fbfea13c45
|
||||
59
.github/ISSUE_TEMPLATE/bug.yaml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
name: Bug Report
|
||||
description: File a bug report.
|
||||
title: "[Bug]: "
|
||||
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
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: |
|
||||
What steps did you take to get to your issue?
|
||||
placeholder: "Tell us what you see!"
|
||||
validations:
|
||||
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
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Rayhunter Mattermost
|
||||
url: https://opensource.eff.org/signup_user_complete/?id=6iqur37ucfrctfswrs14iscobw&md=link&sbr=su
|
||||
about: If you're having trouble using Rayhunter and aren't sure you've found a bug or request for a new feature, please first try asking for help here. There is a much larger community there of people familiar with the project who will be able to more quickly answer your questions.
|
||||
- name: Rayhunter Security Policy
|
||||
url: https://github.com/EFForg/rayhunter/security/advisories/new
|
||||
about: Please report security vulnerabilities here.
|
||||
27
.github/ISSUE_TEMPLATE/feature.yaml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or improvement to Rayhunter
|
||||
title: "[Feature Request]: "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: What problem does this feature solve or what does it enhance?
|
||||
description: Explain what this feature addresses, ors the benefit it provides.
|
||||
placeholder: For example, "Currently, users have to manually do X, which is time-consuming."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: Describe the solution you'd like to see implemented.
|
||||
placeholder: For example, "Implement a new button that automatically does X."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Have you considered any alternative solutions?
|
||||
placeholder: For example, "We considered Y, but Z is a better approach because..."
|
||||
6
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
## Pull Request Checklist
|
||||
|
||||
- [ ] The Rayhunter team has recently expressed interest in reviewing a PR for this. If not, this PR may be closed due our limited resources and need to prioritize how we spend them.
|
||||
- [ ] Added or updated any documentation as needed to support the changes in this PR.
|
||||
- [ ] Code has been linted and run through `cargo fmt`
|
||||
- [ ] If any new functionality has been added, unit tests were also added
|
||||
157
.github/workflows/build-release.yml
vendored
@@ -3,83 +3,160 @@ name: Build Release
|
||||
on:
|
||||
push:
|
||||
branches: [main, "release-*"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
FILE_ROOTSHELL: ../../rootshell/rootshell
|
||||
FILE_RAYHUNTER_DAEMON_ORBIC: ../../rayhunter-daemon-orbic/rayhunter-daemon
|
||||
FILE_RAYHUNTER_DAEMON_TPLINK: ../../rayhunter-daemon-tplink/rayhunter-daemon
|
||||
|
||||
jobs:
|
||||
build_serial_and_check:
|
||||
build_rayhunter_check:
|
||||
strategy:
|
||||
matrix:
|
||||
platform:
|
||||
- os: ubuntu-latest
|
||||
serial_build_name: serial
|
||||
check_build_name: rayhunter-check
|
||||
- os: macos-latest
|
||||
serial_build_name: serial
|
||||
check_build_name: rayhunter-check
|
||||
- name: ubuntu-24
|
||||
os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-musl
|
||||
- name: ubuntu-24-aarch64
|
||||
os: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-musl
|
||||
- name: macos-arm
|
||||
os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
- name: macos-intel
|
||||
os: macos-13
|
||||
target: x86_64-apple-darwin
|
||||
- name: windows-x86_64
|
||||
os: windows-latest
|
||||
target: x86_64-pc-windows-gnu
|
||||
runs-on: ${{ matrix.platform.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build serial
|
||||
run: cargo build --bin serial --release
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: serial-${{ matrix.platform.os }}
|
||||
path: ./target/release/${{ matrix.platform.serial_build_name }}
|
||||
if-no-files-found: error
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build check
|
||||
- name: Build rayhunter-check
|
||||
run: cargo build --bin rayhunter-check --release
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rayhunter-check-${{ matrix.platform.os }}
|
||||
path: ./target/release/${{ matrix.platform.check_build_name }}
|
||||
name: rayhunter-check-${{ matrix.platform.name }}
|
||||
path: target/release/rayhunter-check${{ matrix.platform.os == 'windows-latest' && '.exe' || '' }}
|
||||
if-no-files-found: error
|
||||
build_rootshell_and_rayhunter:
|
||||
build_rootshell:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: armv7-unknown-linux-gnueabihf
|
||||
- name: Install cross-compilation dependencies
|
||||
uses: awalsh128/cache-apt-pkgs-action@latest
|
||||
with:
|
||||
packages: build-essential libc6-armhf-cross libc6-dev-armhf-cross gcc-arm-linux-gnueabihf
|
||||
version: 1.0
|
||||
targets: armv7-unknown-linux-musleabihf
|
||||
- name: Build rootshell (arm32)
|
||||
run: cargo build --bin rootshell --target armv7-unknown-linux-gnueabihf --release
|
||||
run: cargo build --bin rootshell --target armv7-unknown-linux-musleabihf --profile=firmware
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rootshell
|
||||
path: target/armv7-unknown-linux-gnueabihf/release/rootshell
|
||||
path: target/armv7-unknown-linux-musleabihf/firmware/rootshell
|
||||
if-no-files-found: error
|
||||
build_rayhunter:
|
||||
strategy:
|
||||
matrix:
|
||||
device:
|
||||
- name: tplink
|
||||
- name: orbic
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: armv7-unknown-linux-musleabihf
|
||||
- name: Build rayhunter-daemon (arm32)
|
||||
run: cargo build --bin rayhunter-daemon --target armv7-unknown-linux-gnueabihf --release
|
||||
run: |
|
||||
pushd bin/web
|
||||
npm install
|
||||
npm run build
|
||||
popd
|
||||
# Run with -p so that cargo will select the minimum feature set for this package.
|
||||
#
|
||||
# Otherwise, it will consider the union of all requested features
|
||||
# from all packages in the workspace. For example, if installer
|
||||
# requires tokio with "full" feature, it will be included no matter
|
||||
# what the feature selection in rayhunter-daemon is.
|
||||
#
|
||||
# https://github.com/rust-lang/cargo/issues/4463
|
||||
cargo build -p rayhunter-daemon --bin rayhunter-daemon --target armv7-unknown-linux-musleabihf --profile=firmware --no-default-features --features ${{ matrix.device.name }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rayhunter-daemon
|
||||
path: target/armv7-unknown-linux-gnueabihf/release/rayhunter-daemon
|
||||
name: rayhunter-daemon-${{ matrix.device.name }}
|
||||
path: target/armv7-unknown-linux-musleabihf/firmware/rayhunter-daemon
|
||||
if-no-files-found: error
|
||||
build_rust_installer:
|
||||
needs:
|
||||
- build_rayhunter
|
||||
strategy:
|
||||
matrix:
|
||||
platform:
|
||||
- name: ubuntu-24
|
||||
os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-musl
|
||||
- name: ubuntu-24-aarch64
|
||||
os: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-musl
|
||||
- name: macos-arm
|
||||
os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
- name: macos-intel
|
||||
os: macos-13
|
||||
target: x86_64-apple-darwin
|
||||
- name: windows-x86_64
|
||||
os: windows-latest
|
||||
target: x86_64-pc-windows-gnu
|
||||
runs-on: ${{ matrix.platform.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.platform.target }}
|
||||
- run: cargo build --bin installer --release --target ${{ matrix.platform.target }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: installer-${{ matrix.platform.name }}
|
||||
path: target/${{ matrix.platform.target }}/release/installer${{ matrix.platform.os == 'windows-latest' && '.exe' || '' }}
|
||||
if-no-files-found: error
|
||||
|
||||
build_release_zip:
|
||||
needs:
|
||||
- build_serial_and_check
|
||||
- build_rootshell_and_rayhunter
|
||||
- build_rayhunter_check
|
||||
- build_rootshell
|
||||
- build_rayhunter
|
||||
- build_rust_installer
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
- name: Fix executable permissions on binaries
|
||||
run: chmod +x serial-*/serial rayhunter-check-*/rayhunter-check rayhunter-daemon/rayhunter-daemon
|
||||
- name: Setup release directory
|
||||
run: mv rayhunter-daemon/rayhunter-daemon rootshell/rootshell serial-* dist
|
||||
- name: Archive release directory
|
||||
run: tar -cvf release.tar -C dist .
|
||||
run: chmod +x installer-*/installer rayhunter-check-*/rayhunter-check rayhunter-daemon-*/rayhunter-daemon
|
||||
- name: Get Rayhunter version
|
||||
id: get_version
|
||||
run: echo "VERSION=$(grep '^version' bin/Cargo.toml | head -n 1 | cut -d'"' -f2)" >> $GITHUB_ENV
|
||||
- name: Setup versioned release directory
|
||||
run: |
|
||||
VERSIONED_DIR="rayhunter-v${{ env.VERSION }}"
|
||||
mkdir "$VERSIONED_DIR"
|
||||
mv rayhunter-daemon-* rootshell/rootshell installer-* dist/* installer/install.ps1 "$VERSIONED_DIR"/
|
||||
- name: Archive release directory as zip
|
||||
run: |
|
||||
VERSIONED_DIR="rayhunter-v${{ env.VERSION }}"
|
||||
zip -r "$VERSIONED_DIR.zip" "$VERSIONED_DIR"
|
||||
- name: Compute SHA256 of zip
|
||||
run: |
|
||||
VERSIONED_DIR="rayhunter-v${{ env.VERSION }}"
|
||||
sha256sum "$VERSIONED_DIR.zip" > "$VERSIONED_DIR.zip.sha256"
|
||||
# TODO: have this create a release directly
|
||||
- name: Upload release
|
||||
- name: Upload zip release and sha256
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release.tar
|
||||
path: release.tar
|
||||
name: rayhunter-v${{ env.VERSION }}
|
||||
path: |
|
||||
rayhunter-v${{ env.VERSION }}.zip
|
||||
rayhunter-v${{ env.VERSION }}.zip.sha256
|
||||
if-no-files-found: error
|
||||
|
||||
38
.github/workflows/check-and-test.yml
vendored
@@ -8,13 +8,47 @@ on:
|
||||
|
||||
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: cargo check --verbose
|
||||
run: |
|
||||
pushd bin/web
|
||||
npm install
|
||||
npm run build
|
||||
popd
|
||||
cargo check --verbose --no-default-features --features=${{ matrix.device.name }}
|
||||
- name: Run tests
|
||||
run: cargo test --verbose
|
||||
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 }}
|
||||
|
||||
47
.github/workflows/mdbook.yaml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
# 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
|
||||
2
.gitignore
vendored
@@ -1 +1,3 @@
|
||||
/target
|
||||
/book
|
||||
.DS_Store
|
||||
|
||||
1
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1 @@
|
||||
This project is governed by [EFF's Public Projects Code of Conduct](https://www.eff.org/pages/eppcode).
|
||||
2348
Cargo.lock
generated
@@ -3,8 +3,8 @@
|
||||
members = [
|
||||
"lib",
|
||||
"bin",
|
||||
"serial",
|
||||
"rootshell",
|
||||
"telcom-parser",
|
||||
"installer",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
96
README.md
@@ -1,97 +1,7 @@
|
||||

|
||||
|
||||
# Rayhunter
|
||||
|
||||
```
|
||||
@@@@@@@ @@@@@@ @@@ @@@ @@@ @@@ @@@ @@@ @@@ @@@ @@@@@@@ @@@@@@@@ @@@@@@@
|
||||
@@! @@@ @@! @@@ @@! !@@ @@! @@@ @@! @@@ @@!@!@@@ @@! @@! @@! @@@
|
||||
@!@!!@! @!@!@!@! !@!@! @!@!@!@! @!@ !@! @!@@!!@! @!! @!!!:! @!@!!@!
|
||||
!!: :!! !!: !!! !!: !!: !!! !!: !!! !!: !!! !!: !!: !!: :!!
|
||||
: : : : : : .: : : : :.:: : :: : : : :: ::: : : :
|
||||
|
||||
|
||||
_ _ _ _ _ _ _ _
|
||||
)`'-.,_)`'-.,_)`'-.,_)`'-.,_)`'-.,_)`'-.,_)`'-.,_)`'-.,_
|
||||
|
||||
O .
|
||||
O ' '
|
||||
o ' .
|
||||
o .'
|
||||
__________.-' '...___
|
||||
.-' ### '''...__
|
||||
/ a### ## ''--.._ ______
|
||||
'. # ######## ' .-'
|
||||
'-._ ..**********#### ___...---'''\ '
|
||||
'-._ __________...---''' \ l
|
||||
\ | apc '._|
|
||||
\__;
|
||||
```
|
||||

|
||||
|
||||
Rayhunter is an IMSI Catcher Catcher for the Orbic mobile hotspot.
|
||||
|
||||
**THIS CODE IS PROOF OF CONCEPT AND SHOULD NOT BE RELIED UPON IN HIGH RISK SITUATIONS**
|
||||
|
||||
Code is built and tested for the Orbic RC400L mobile hotspot, it may work on other orbics and other
|
||||
linux/qualcom devices but this is the only one we have tested on. Buy the orbic [using bezos bucks](https://www.amazon.com/gp/product/B09CLS6Z7X/)
|
||||
|
||||
## Setup
|
||||
|
||||
*NOTE: We don't currently support automated installs on windows, you will have to follow the manual install instructions below*
|
||||
|
||||
1. Download the latest [rayhunter release bundle](https://github.com/EFForg/rayhunter/releases) and extract it.
|
||||
2. Run the install script inside the bundle corresponding to your platform (`install-linux.sh`, `install-mac.sh`).
|
||||
3. Once finished, rayhunter should be running! You can verify this by visiting the web UI as described below.
|
||||
|
||||
## Usage
|
||||
|
||||
Once installed, rayhunter will run automatically whenever your Orbic device is running. It serves a web UI that provides some basic controls, such as being able to start/stop recordings, download captures, and view heuristic analyses of captures. You can access this UI in one of two ways:
|
||||
|
||||
1. Over wifi: Connect your phone/laptop to the Orbic's wifi network and visit `http://192.168.1.1:8080` (click past your browser warning you about the connection not being secure, rayhunter doesn't have HTTPS yet!)
|
||||
* Note that you'll need the Orbic's wifi password for this, which can be retrieved by pressing the "MENU" button on the device and opening the 2.4 GHz menu.
|
||||
2. Over usb: Connect the Orbic device to your laptop via usb. Run `adb forward tcp:8080 tcp:8080`, then visit `http://localhost:8080`. For this you will need to install the Android Debug Bridge (ADB) on your computer, you can copy the version that was downloaded inside the releases/platform-tools/` folder to somewhere else in your path or you can install it manually. You can find instructions for doing so on your platform [here](https://www.xda-developers.com/install-adb-windows-macos-linux/#how-to-set-up-adb-on-your-computer), (don't worry about instructions for installing it on a phone/device yet).
|
||||
|
||||
## Development
|
||||
* Install ADB on your computer using the instructions above, and make sure it's in your terminal's PATH
|
||||
* You can verify if ADB is in your PATH by running `which adb` in a terminal. If it prints the filepath to where ADB is installed, you're set! Otherwise, try following one of these guides:
|
||||
* [linux](https://askubuntu.com/questions/652936/adding-android-sdk-platform-tools-to-path-downloaded-from-umake)
|
||||
* [macOS](https://www.repeato.app/setting-up-adb-on-macos-a-step-by-step-guide/)
|
||||
* [Windows](https://medium.com/@yadav-ajay/a-step-by-step-guide-to-setting-up-adb-path-on-windows-0b833faebf18)
|
||||
|
||||
### If your are on x86 linux
|
||||
* on your linux laptop install rust the usual way and then install cross compiling dependences.
|
||||
* run `sudo apt install build-essential libc6-armhf-cross libc6-dev-armhf-cross gcc-arm-linux-gnueabihf`
|
||||
|
||||
* set up cross compliing for rust:
|
||||
```
|
||||
rustup target add x86_64-unknown-linux-gnu
|
||||
rustup target add armv7-unknown-linux-gnueabihf
|
||||
```
|
||||
|
||||
Now you can root your device and install rayhunter by running `./tools/install-dev.sh`
|
||||
|
||||
### If you are on windows or can't run the install scripts
|
||||
* Root your device on windows using the instructions here: https://xdaforums.com/t/resetting-verizon-orbic-speed-rc400l-firmware-flash-kajeet.4334899/#post-87855183
|
||||
|
||||
* Build for arm using `cargo build`
|
||||
|
||||
* Run tests using `cargo test_pc`
|
||||
|
||||
* Push the scripts in `scripts/` to /etc/init.d on device and make a directory called /data/rayhunter using `adb shell` (and sshell for your root shell if you followed the steps above)
|
||||
|
||||
* you also need to copy `config.toml.example` to /data/rayhunter/config.toml
|
||||
|
||||
* Then run `./make.sh` this will build the binary and push it over adb. Restart your device or run `/etc/init.d/rayhunter_daemon start` on the device and you are good to go.
|
||||
|
||||
* Write your code and write tests
|
||||
|
||||
* Build for arm using `cargo build`
|
||||
|
||||
* Run tests using `cargo test_pc`
|
||||
|
||||
* push to the device with `./make.sh`
|
||||
|
||||
## Documentation
|
||||
* Build docs locallly using `RUSTDOCFLAGS="--cfg docsrs" cargo doc --no-deps --all-features --open`
|
||||
|
||||
**LEGAL DISCLAIMER:** Use this program at your own risk. We beilieve running this program does not currently violate any laws or regulations in the United States. However, we are not responsible for civil or criminal liability resulting from the use of this software. If you are located outside of the US please consult with an attorney in your country to help you assess the legal risks of running this program.
|
||||
|
||||
*Good Hunting!*
|
||||
Rayhunter is an IMSI Catcher Catcher for the Orbic mobile hotspot. To learn more, check out the [Rayhunter Book](https://efforg.github.io/rayhunter/).
|
||||
|
||||
5
SECURITY.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Security vulnerabilities can be reported using GitHub's [private vulnerability reporting tool](https://github.com/EFForg/rayhunter/security/advisories/new).
|
||||
@@ -1,8 +1,15 @@
|
||||
[package]
|
||||
name = "rayhunter-daemon"
|
||||
version = "0.1.0"
|
||||
version = "0.3.3"
|
||||
edition = "2021"
|
||||
|
||||
[features]
|
||||
# These feature flags are mutually exclusive, and exactly one must be enabled.
|
||||
orbic = ["rayhunter/orbic"]
|
||||
tplink = ["rayhunter/tplink"]
|
||||
|
||||
default = ["orbic"]
|
||||
|
||||
[[bin]]
|
||||
name = "rayhunter-daemon"
|
||||
path = "src/daemon.rs"
|
||||
@@ -15,20 +22,21 @@ path = "src/check.rs"
|
||||
rayhunter = { path = "../lib" }
|
||||
toml = "0.8.8"
|
||||
serde = { version = "1.0.193", features = ["derive"] }
|
||||
tokio = { version = "1.35.1", features = ["full"] }
|
||||
axum = "0.7.3"
|
||||
futures-core = "0.3.30"
|
||||
tokio = { version = "1.44.2", default-features = false, features = ["fs", "signal", "process", "rt-multi-thread"] }
|
||||
axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json"] }
|
||||
thiserror = "1.0.52"
|
||||
libc = "0.2.150"
|
||||
log = "0.4.20"
|
||||
env_logger = "0.10.1"
|
||||
tokio-util = { version = "0.7.10", features = ["rt"] }
|
||||
env_logger = { version = "0.11", default-features = false }
|
||||
tokio-util = { version = "0.7.10", features = ["rt", "io"] }
|
||||
futures-macro = "0.3.30"
|
||||
include_dir = "0.7.3"
|
||||
mime_guess = "2.0.4"
|
||||
chrono = { version = "0.4.31", features = ["serde"] }
|
||||
tokio-stream = "0.1.14"
|
||||
futures = "0.3.30"
|
||||
tokio-stream = { version = "0.1.14", default-features = false }
|
||||
futures = { version = "0.3.30", default-features = false }
|
||||
clap = { version = "4.5.2", features = ["derive"] }
|
||||
serde_json = "1.0.114"
|
||||
image = "0.25.1"
|
||||
image = { version = "0.25.1", default-features = false, features = ["png", "gif"] }
|
||||
tempfile = "3.10.1"
|
||||
simple_logger = "5.0.0"
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
@@ -18,9 +18,9 @@ use tokio::sync::mpsc::Receiver;
|
||||
use tokio::sync::{RwLock, RwLockWriteGuard};
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
use crate::dummy_analyzer::TestAnalyzer;
|
||||
use crate::qmdl_store::RecordingStore;
|
||||
use crate::server::ServerState;
|
||||
use crate::dummy_analyzer::TestAnalyzer;
|
||||
|
||||
pub struct AnalysisWriter {
|
||||
writer: BufWriter<File>,
|
||||
@@ -53,7 +53,10 @@ impl AnalysisWriter {
|
||||
|
||||
// Runs the analysis harness on the given container, serializing the results
|
||||
// to the analysis file and returning the file's new length.
|
||||
pub async fn analyze(&mut self, container: MessagesContainer) -> Result<(usize, bool), std::io::Error> {
|
||||
pub async fn analyze(
|
||||
&mut self,
|
||||
container: MessagesContainer,
|
||||
) -> Result<(usize, bool), std::io::Error> {
|
||||
let row = self.harness.analyze_qmdl_messages(container);
|
||||
if !row.is_empty() {
|
||||
self.write(&row).await?;
|
||||
@@ -77,14 +80,32 @@ impl AnalysisWriter {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone, Default)]
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct AnalysisStatus {
|
||||
queued: Vec<String>,
|
||||
running: Option<String>,
|
||||
finished: Vec<String>,
|
||||
}
|
||||
|
||||
impl AnalysisStatus {
|
||||
pub fn new(store: &RecordingStore) -> Self {
|
||||
let existing_recordings: Vec<String> = store
|
||||
.manifest
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| entry.name.clone())
|
||||
.collect();
|
||||
AnalysisStatus {
|
||||
queued: Vec::new(),
|
||||
running: None,
|
||||
finished: existing_recordings,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum AnalysisCtrlMessage {
|
||||
NewFilesQueued,
|
||||
RecordingFinished(String),
|
||||
Exit,
|
||||
}
|
||||
|
||||
@@ -100,9 +121,10 @@ async fn dequeue_to_running(analysis_status_lock: Arc<RwLock<AnalysisStatus>>) -
|
||||
name
|
||||
}
|
||||
|
||||
async fn clear_running(analysis_status_lock: Arc<RwLock<AnalysisStatus>>) {
|
||||
async fn finish_running_analysis(analysis_status_lock: Arc<RwLock<AnalysisStatus>>) {
|
||||
let mut analysis_status = analysis_status_lock.write().await;
|
||||
analysis_status.running = None;
|
||||
let finished = analysis_status.running.take().unwrap();
|
||||
analysis_status.finished.push(finished);
|
||||
}
|
||||
|
||||
async fn perform_analysis(
|
||||
@@ -114,7 +136,7 @@ async fn perform_analysis(
|
||||
let (analysis_file, qmdl_file, entry_index) = {
|
||||
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||
let (entry_index, _) = qmdl_store
|
||||
.entry_for_name(&name)
|
||||
.entry_for_name(name)
|
||||
.ok_or(format!("failed to find QMDL store entry for {}", name))?;
|
||||
let analysis_file = qmdl_store
|
||||
.clear_and_open_entry_analysis(entry_index)
|
||||
@@ -182,12 +204,19 @@ pub fn run_analysis_thread(
|
||||
let count = queued_len(analysis_status_lock.clone()).await;
|
||||
for _ in 0..count {
|
||||
let name = dequeue_to_running(analysis_status_lock.clone()).await;
|
||||
if let Err(err) = perform_analysis(&name, qmdl_store_lock.clone(), enable_dummy_analyzer).await {
|
||||
if let Err(err) =
|
||||
perform_analysis(&name, qmdl_store_lock.clone(), enable_dummy_analyzer)
|
||||
.await
|
||||
{
|
||||
error!("failed to analyze {}: {}", name, err);
|
||||
}
|
||||
clear_running(analysis_status_lock.clone()).await;
|
||||
finish_running_analysis(analysis_status_lock.clone()).await;
|
||||
}
|
||||
}
|
||||
Some(AnalysisCtrlMessage::RecordingFinished(name)) => {
|
||||
let mut status = analysis_status_lock.write().await;
|
||||
status.finished.push(name);
|
||||
}
|
||||
Some(AnalysisCtrlMessage::Exit) | None => return,
|
||||
}
|
||||
}
|
||||
|
||||
132
bin/src/check.rs
@@ -1,35 +1,61 @@
|
||||
use std::{collections::HashMap, future, path::PathBuf, pin::pin};
|
||||
use rayhunter::{analysis::analyzer::Harness, diag::DataType, qmdl::QmdlReader};
|
||||
use tokio::fs::{metadata, read_dir, File};
|
||||
use clap::Parser;
|
||||
use futures::TryStreamExt;
|
||||
use log::{info, warn};
|
||||
use rayhunter::{
|
||||
analysis::analyzer::{EventType, Harness},
|
||||
diag::DataType,
|
||||
gsmtap_parser,
|
||||
pcap::GsmtapPcapWriter,
|
||||
qmdl::QmdlReader,
|
||||
};
|
||||
use std::{collections::HashMap, future, path::PathBuf, pin::pin};
|
||||
use tokio::fs::{metadata, read_dir, File};
|
||||
|
||||
mod dummy_analyzer;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version, about)]
|
||||
struct Args {
|
||||
#[arg(short, long)]
|
||||
#[arg(short = 'p', long)]
|
||||
qmdl_path: PathBuf,
|
||||
|
||||
#[arg(short = 'c', long)]
|
||||
pcapify: bool,
|
||||
|
||||
#[arg(long)]
|
||||
show_skipped: bool,
|
||||
|
||||
#[arg(long)]
|
||||
enable_dummy_analyzer: bool,
|
||||
|
||||
#[arg(short, long)]
|
||||
verbose: bool,
|
||||
}
|
||||
|
||||
async fn analyze_file(harness: &mut Harness, 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();
|
||||
if enable_dummy_analyzer {
|
||||
harness.add_analyzer(Box::new(dummy_analyzer::TestAnalyzer { count: 0 }));
|
||||
}
|
||||
let qmdl_file = &mut File::open(&qmdl_path).await.expect("failed to open file");
|
||||
let file_size = qmdl_file.metadata().await.expect("failed to get QMDL file metadata").len();
|
||||
let file_size = qmdl_file
|
||||
.metadata()
|
||||
.await
|
||||
.expect("failed to get QMDL file metadata")
|
||||
.len();
|
||||
let mut qmdl_reader = QmdlReader::new(qmdl_file, Some(file_size as usize));
|
||||
let mut qmdl_stream = pin!(qmdl_reader.as_stream()
|
||||
let mut qmdl_stream = pin!(qmdl_reader
|
||||
.as_stream()
|
||||
.try_filter(|container| future::ready(container.data_type == DataType::UserSpace)));
|
||||
let mut skipped_reasons: HashMap<String, i32> = HashMap::new();
|
||||
let mut total_messages = 0;
|
||||
let mut warnings = 0;
|
||||
let mut skipped = 0;
|
||||
while let Some(container) = qmdl_stream.try_next().await.expect("failed getting QMDL container") {
|
||||
while let Some(container) = qmdl_stream
|
||||
.try_next()
|
||||
.await
|
||||
.expect("failed getting QMDL container")
|
||||
{
|
||||
let row = harness.analyze_qmdl_messages(container);
|
||||
total_messages += 1;
|
||||
for reason in row.skipped_message_reasons {
|
||||
@@ -38,47 +64,113 @@ async fn analyze_file(harness: &mut Harness, qmdl_path: &str, show_skipped: bool
|
||||
}
|
||||
for analysis in row.analysis {
|
||||
for maybe_event in analysis.events {
|
||||
if let Some(event) = maybe_event {
|
||||
warnings += 1;
|
||||
println!("{}: {:?}", analysis.timestamp, event);
|
||||
let Some(event) = maybe_event else { continue };
|
||||
match event.event_type {
|
||||
EventType::Informational => {
|
||||
info!(
|
||||
"{}: INFO - {} {}",
|
||||
qmdl_path, analysis.timestamp, event.message,
|
||||
);
|
||||
}
|
||||
EventType::QualitativeWarning { severity } => {
|
||||
warn!(
|
||||
"{}: WARNING (Severity: {:?}) - {} {}",
|
||||
qmdl_path, severity, analysis.timestamp, event.message,
|
||||
);
|
||||
warnings += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if show_skipped && skipped > 0 {
|
||||
println!("{}: messages skipped:", qmdl_path);
|
||||
info!("{}: messages skipped:", qmdl_path);
|
||||
for (reason, count) in skipped_reasons.iter() {
|
||||
println!(" - {}: \"{}\"", count, reason);
|
||||
info!(" - {}: \"{}\"", count, reason);
|
||||
}
|
||||
}
|
||||
println!("{}: {} messages analyzed, {} warnings, {} messages skipped", qmdl_path, total_messages, warnings, skipped);
|
||||
info!(
|
||||
"{}: {} messages analyzed, {} warnings, {} messages skipped",
|
||||
qmdl_path, total_messages, warnings, skipped
|
||||
);
|
||||
}
|
||||
|
||||
async fn pcapify(qmdl_path: &PathBuf) {
|
||||
let qmdl_file = &mut File::open(&qmdl_path)
|
||||
.await
|
||||
.expect("failed to open qmdl file");
|
||||
let qmdl_file_size = qmdl_file.metadata().await.unwrap().len();
|
||||
let mut qmdl_reader = QmdlReader::new(qmdl_file, Some(qmdl_file_size as usize));
|
||||
let mut pcap_path = qmdl_path.clone();
|
||||
pcap_path.set_extension("pcap");
|
||||
let pcap_file = &mut File::create(&pcap_path)
|
||||
.await
|
||||
.expect("failed to open pcap file");
|
||||
let mut pcap_writer = GsmtapPcapWriter::new(pcap_file).await.unwrap();
|
||||
pcap_writer.write_iface_header().await.unwrap();
|
||||
while let Some(container) = qmdl_reader
|
||||
.get_next_messages_container()
|
||||
.await
|
||||
.expect("failed to get container")
|
||||
{
|
||||
for msg in container.into_messages().into_iter().flatten() {
|
||||
if let Ok(Some((timestamp, parsed))) = gsmtap_parser::parse(msg) {
|
||||
pcap_writer
|
||||
.write_gsmtap_message(parsed, timestamp)
|
||||
.await
|
||||
.expect("failed to write");
|
||||
}
|
||||
}
|
||||
}
|
||||
info!("wrote pcap to {:?}", &pcap_path);
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
env_logger::init();
|
||||
let args = Args::parse();
|
||||
let level = if args.verbose {
|
||||
log::LevelFilter::Trace
|
||||
} else {
|
||||
log::LevelFilter::Warn
|
||||
};
|
||||
simple_logger::SimpleLogger::new()
|
||||
.with_colors(true)
|
||||
.without_timestamps()
|
||||
.with_level(level)
|
||||
.init()
|
||||
.unwrap();
|
||||
info!("Analyzers:");
|
||||
|
||||
let mut harness = Harness::new_with_all_analyzers();
|
||||
if args.enable_dummy_analyzer {
|
||||
harness.add_analyzer(Box::new(dummy_analyzer::TestAnalyzer { count: 0 }));
|
||||
}
|
||||
println!("Analyzers:");
|
||||
for analyzer in harness.get_metadata().analyzers {
|
||||
println!(" - {}: {}", analyzer.name, analyzer.description);
|
||||
info!(" - {}: {}", analyzer.name, analyzer.description);
|
||||
}
|
||||
|
||||
let metadata = metadata(&args.qmdl_path).await.expect("failed to get metadata");
|
||||
let metadata = metadata(&args.qmdl_path)
|
||||
.await
|
||||
.expect("failed to get metadata");
|
||||
if metadata.is_dir() {
|
||||
let mut dir = read_dir(&args.qmdl_path).await.expect("failed to read dir");
|
||||
while let Some(entry) = dir.next_entry().await.expect("failed to get entry") {
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_str().unwrap();
|
||||
if name_str.ends_with(".qmdl") {
|
||||
analyze_file(&mut harness, entry.path().to_str().unwrap(), args.show_skipped).await;
|
||||
let path = entry.path();
|
||||
let path_str = path.to_str().unwrap();
|
||||
analyze_file(args.enable_dummy_analyzer, path_str, args.show_skipped).await;
|
||||
if args.pcapify {
|
||||
pcapify(&path).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
analyze_file(&mut harness, args.qmdl_path.to_str().unwrap(), args.show_skipped).await;
|
||||
let path = args.qmdl_path.to_str().unwrap();
|
||||
analyze_file(args.enable_dummy_analyzer, path, args.show_skipped).await;
|
||||
if args.pcapify {
|
||||
pcapify(&args.qmdl_path).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,22 +2,16 @@ use crate::error::RayhunterError;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ConfigFile {
|
||||
qmdl_store_path: Option<String>,
|
||||
port: Option<u16>,
|
||||
debug_mode: Option<bool>,
|
||||
ui_level: Option<u8>,
|
||||
enable_dummy_analyzer: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
pub qmdl_store_path: String,
|
||||
pub port: u16,
|
||||
pub debug_mode: bool,
|
||||
pub ui_level: u8,
|
||||
pub enable_dummy_analyzer: bool,
|
||||
pub colorblind_mode: bool,
|
||||
pub key_input_mode: u8,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
@@ -28,22 +22,21 @@ impl Default for Config {
|
||||
debug_mode: false,
|
||||
ui_level: 1,
|
||||
enable_dummy_analyzer: false,
|
||||
colorblind_mode: false,
|
||||
key_input_mode: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_config<P>(path: P) -> Result<Config, RayhunterError> where P: AsRef<std::path::Path> {
|
||||
let mut config = Config::default();
|
||||
pub fn parse_config<P>(path: P) -> Result<Config, RayhunterError>
|
||||
where
|
||||
P: AsRef<std::path::Path>,
|
||||
{
|
||||
if let Ok(config_file) = std::fs::read_to_string(&path) {
|
||||
let parsed_config: ConfigFile = toml::from_str(&config_file)
|
||||
.map_err(RayhunterError::ConfigFileParsingError)?;
|
||||
parsed_config.qmdl_store_path.map(|v| config.qmdl_store_path = v);
|
||||
parsed_config.port.map(|v| config.port = v);
|
||||
parsed_config.debug_mode.map(|v| config.debug_mode = v);
|
||||
parsed_config.ui_level.map(|v| config.ui_level = v);
|
||||
parsed_config.enable_dummy_analyzer.map(|v| config.enable_dummy_analyzer = v);
|
||||
Ok(toml::from_str(&config_file).map_err(RayhunterError::ConfigFileParsingError)?)
|
||||
} else {
|
||||
Ok(Config::default())
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub struct Args {
|
||||
|
||||
@@ -1,42 +1,63 @@
|
||||
mod analysis;
|
||||
mod config;
|
||||
mod diag;
|
||||
mod display;
|
||||
mod dummy_analyzer;
|
||||
mod error;
|
||||
mod key_input;
|
||||
mod pcap;
|
||||
mod qmdl_store;
|
||||
mod server;
|
||||
mod stats;
|
||||
mod qmdl_store;
|
||||
mod diag;
|
||||
mod framebuffer;
|
||||
mod dummy_analyzer;
|
||||
|
||||
use crate::config::{parse_config, parse_args};
|
||||
use crate::config::{parse_args, parse_config};
|
||||
use crate::diag::run_diag_read_thread;
|
||||
use crate::qmdl_store::RecordingStore;
|
||||
use crate::server::{ServerState, get_qmdl, serve_static};
|
||||
use crate::pcap::get_pcap;
|
||||
use crate::stats::get_system_stats;
|
||||
use crate::error::RayhunterError;
|
||||
use crate::framebuffer::Framebuffer;
|
||||
use crate::pcap::get_pcap;
|
||||
use crate::qmdl_store::RecordingStore;
|
||||
use crate::server::{get_qmdl, serve_static, ServerState};
|
||||
use crate::stats::get_system_stats;
|
||||
|
||||
use analysis::{get_analysis_status, run_analysis_thread, start_analysis, AnalysisCtrlMessage, AnalysisStatus};
|
||||
use analysis::{
|
||||
get_analysis_status, run_analysis_thread, start_analysis, AnalysisCtrlMessage, AnalysisStatus,
|
||||
};
|
||||
use axum::response::Redirect;
|
||||
use diag::{get_analysis_report, start_recording, stop_recording, DiagDeviceCtrlMessage};
|
||||
use log::{info, error};
|
||||
use rayhunter::diag_device::DiagDevice;
|
||||
use axum::routing::{get, post};
|
||||
use axum::Router;
|
||||
use diag::{
|
||||
delete_all_recordings, delete_recording, get_analysis_report, start_recording, stop_recording,
|
||||
DiagDeviceCtrlMessage,
|
||||
};
|
||||
use log::{error, info};
|
||||
use qmdl_store::RecordingStoreError;
|
||||
use rayhunter::diag_device::DiagDevice;
|
||||
use stats::get_qmdl_manifest;
|
||||
use tokio::sync::mpsc::{self, Sender, Receiver};
|
||||
use tokio::sync::oneshot::error::TryRecvError;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::mpsc::{self, Sender};
|
||||
use tokio::sync::{oneshot, RwLock};
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_util::task::TaskTracker;
|
||||
use std::net::SocketAddr;
|
||||
use std::thread::sleep;
|
||||
use std::time::Duration;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::{RwLock, oneshot};
|
||||
use std::sync::Arc;
|
||||
use include_dir::{include_dir, Dir};
|
||||
|
||||
type AppRouter = Router<Arc<ServerState>>;
|
||||
|
||||
fn get_router() -> AppRouter {
|
||||
Router::new()
|
||||
.route("/api/pcap/{name}", get(get_pcap))
|
||||
.route("/api/qmdl/{name}", get(get_qmdl))
|
||||
.route("/api/system-stats", get(get_system_stats))
|
||||
.route("/api/qmdl-manifest", get(get_qmdl_manifest))
|
||||
.route("/api/start-recording", post(start_recording))
|
||||
.route("/api/stop-recording", post(stop_recording))
|
||||
.route("/api/delete-recording/{name}", post(delete_recording))
|
||||
.route("/api/delete-all-recordings", post(delete_all_recordings))
|
||||
.route("/api/analysis-report/{name}", get(get_analysis_report))
|
||||
.route("/api/analysis", get(get_analysis_status))
|
||||
.route("/api/analysis/{name}", post(start_analysis))
|
||||
.route("/", get(|| async { Redirect::permanent("/index.html") }))
|
||||
.route("/{*path}", get(serve_static))
|
||||
}
|
||||
|
||||
// Runs the axum server, taking all the elements needed to build up our
|
||||
// ServerState and a oneshot Receiver that'll fire when it's time to shutdown
|
||||
@@ -44,43 +65,19 @@ use include_dir::{include_dir, Dir};
|
||||
async fn run_server(
|
||||
task_tracker: &TaskTracker,
|
||||
config: &config::Config,
|
||||
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
|
||||
state: Arc<ServerState>,
|
||||
server_shutdown_rx: oneshot::Receiver<()>,
|
||||
ui_update_tx: Sender<framebuffer::DisplayState>,
|
||||
diag_device_sender: Sender<DiagDeviceCtrlMessage>,
|
||||
analysis_sender: Sender<AnalysisCtrlMessage>,
|
||||
analysis_status_lock: Arc<RwLock<AnalysisStatus>>,
|
||||
) -> JoinHandle<()> {
|
||||
info!("spinning up server");
|
||||
let state = Arc::new(ServerState {
|
||||
qmdl_store_lock,
|
||||
diag_device_ctrl_sender: diag_device_sender,
|
||||
ui_update_sender: ui_update_tx,
|
||||
debug_mode: config.debug_mode,
|
||||
analysis_status_lock,
|
||||
analysis_sender,
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route("/api/pcap/*name", get(get_pcap))
|
||||
.route("/api/qmdl/*name", get(get_qmdl))
|
||||
.route("/api/system-stats", get(get_system_stats))
|
||||
.route("/api/qmdl-manifest", get(get_qmdl_manifest))
|
||||
.route("/api/start-recording", post(start_recording))
|
||||
.route("/api/stop-recording", post(stop_recording))
|
||||
.route("/api/analysis-report/*name", get(get_analysis_report))
|
||||
.route("/api/analysis", get(get_analysis_status))
|
||||
.route("/api/analysis/*name", post(start_analysis))
|
||||
.route("/", get(|| async { Redirect::permanent("/index.html") }))
|
||||
.route("/*path", get(serve_static))
|
||||
.with_state(state);
|
||||
let app = get_router().with_state(state);
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], config.port));
|
||||
let listener = TcpListener::bind(&addr).await.unwrap();
|
||||
task_tracker.spawn(async move {
|
||||
info!("The orca is hunting for stingrays...");
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(server_shutdown_signal(server_shutdown_rx))
|
||||
.await.unwrap();
|
||||
.await
|
||||
.unwrap();
|
||||
})
|
||||
}
|
||||
|
||||
@@ -89,13 +86,31 @@ async fn server_shutdown_signal(server_shutdown_rx: oneshot::Receiver<()>) {
|
||||
info!("Server received shutdown signal, exiting...");
|
||||
}
|
||||
|
||||
// Loads a QmdlStore if one exists, and if not, only create one if we're not in
|
||||
// debug mode.
|
||||
// Loads a RecordingStore if one exists, and if not, only create one if we're
|
||||
// not in debug mode. If we fail to parse the manifest AND we're not in debug
|
||||
// mode, try to recover by making a new (empty) manifest in the same directory.
|
||||
async fn init_qmdl_store(config: &config::Config) -> Result<RecordingStore, RayhunterError> {
|
||||
match (RecordingStore::exists(&config.qmdl_store_path).await?, config.debug_mode) {
|
||||
(true, _) => Ok(RecordingStore::load(&config.qmdl_store_path).await?),
|
||||
(false, false) => Ok(RecordingStore::create(&config.qmdl_store_path).await?),
|
||||
(false, true) => Err(RayhunterError::NoStoreDebugMode(config.qmdl_store_path.clone())),
|
||||
let store_exists = RecordingStore::exists(&config.qmdl_store_path).await?;
|
||||
if config.debug_mode {
|
||||
if store_exists {
|
||||
Ok(RecordingStore::load(&config.qmdl_store_path).await?)
|
||||
} else {
|
||||
Err(RayhunterError::NoStoreDebugMode(
|
||||
config.qmdl_store_path.clone(),
|
||||
))
|
||||
}
|
||||
} else if store_exists {
|
||||
match RecordingStore::load(&config.qmdl_store_path).await {
|
||||
Ok(store) => Ok(store),
|
||||
Err(RecordingStoreError::ParseManifestError(err)) => {
|
||||
error!("failed to parse QMDL manifest: {}", err);
|
||||
info!("creating new empty manifest...");
|
||||
Ok(RecordingStore::create(&config.qmdl_store_path).await?)
|
||||
}
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
} else {
|
||||
Ok(RecordingStore::create(&config.qmdl_store_path).await?)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,18 +135,24 @@ fn run_ctrl_c_thread(
|
||||
info!("Done!");
|
||||
}
|
||||
|
||||
server_shutdown_tx.send(())
|
||||
server_shutdown_tx
|
||||
.send(())
|
||||
.expect("couldn't send server shutdown signal");
|
||||
info!("sending UI shutdown");
|
||||
if let Some(ui_shutdown_tx) = maybe_ui_shutdown_tx {
|
||||
ui_shutdown_tx.send(())
|
||||
ui_shutdown_tx
|
||||
.send(())
|
||||
.expect("couldn't send ui shutdown signal");
|
||||
}
|
||||
diag_device_sender.send(DiagDeviceCtrlMessage::Exit).await
|
||||
diag_device_sender
|
||||
.send(DiagDeviceCtrlMessage::Exit)
|
||||
.await
|
||||
.expect("couldn't send Exit message to diag thread");
|
||||
analysis_tx.send(AnalysisCtrlMessage::Exit).await
|
||||
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);
|
||||
}
|
||||
@@ -140,64 +161,6 @@ fn run_ctrl_c_thread(
|
||||
})
|
||||
}
|
||||
|
||||
fn update_ui(task_tracker: &TaskTracker, config: &config::Config, mut ui_shutdown_rx: oneshot::Receiver<()>, mut ui_update_rx: Receiver<framebuffer::DisplayState>) -> JoinHandle<()> {
|
||||
static IMAGE_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/static/images/");
|
||||
let display_level = config.ui_level;
|
||||
if display_level == 0 {
|
||||
info!("Invisible mode, not spawning UI.");
|
||||
}
|
||||
|
||||
let mut display_color = framebuffer::Color565::Green;
|
||||
|
||||
task_tracker.spawn_blocking(move || {
|
||||
let mut fb: Framebuffer = Framebuffer::new();
|
||||
// this feels wrong, is there a more rusty way to do this?
|
||||
let mut img: Option<&[u8]> = None;
|
||||
if display_level == 2 {
|
||||
img = Some(IMAGE_DIR.get_file("orca.gif").expect("failed to read orca.gif").contents());
|
||||
} else if display_level == 3 {
|
||||
img = Some(IMAGE_DIR.get_file("eff.png").expect("failed to read eff.png").contents());
|
||||
}
|
||||
loop {
|
||||
match ui_shutdown_rx.try_recv() {
|
||||
Ok(_) => {
|
||||
info!("received UI shutdown");
|
||||
break;
|
||||
},
|
||||
Err(TryRecvError::Empty) => {},
|
||||
Err(e) => panic!("error receiving shutdown message: {e}")
|
||||
}
|
||||
match ui_update_rx.try_recv() {
|
||||
Ok(state) => {
|
||||
display_color = state.into();
|
||||
},
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {},
|
||||
Err(e) => error!("error receiving framebuffer update message: {e}")
|
||||
}
|
||||
|
||||
match display_level {
|
||||
2 => {
|
||||
fb.draw_gif(img.unwrap());
|
||||
},
|
||||
3 => {
|
||||
fb.draw_img(img.unwrap())
|
||||
},
|
||||
128 => {
|
||||
fb.draw_line(framebuffer::Color565::Cyan, 128);
|
||||
fb.draw_line(framebuffer::Color565::Pink, 102);
|
||||
fb.draw_line(framebuffer::Color565::White, 76);
|
||||
fb.draw_line(framebuffer::Color565::Pink, 50);
|
||||
fb.draw_line(framebuffer::Color565::Cyan, 25);
|
||||
},
|
||||
1 | _ => {
|
||||
fb.draw_line(display_color, 2);
|
||||
},
|
||||
};
|
||||
sleep(Duration::from_millis(1000));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), RayhunterError> {
|
||||
env_logger::init();
|
||||
@@ -210,30 +173,66 @@ async fn main() -> Result<(), RayhunterError> {
|
||||
let task_tracker = TaskTracker::new();
|
||||
println!("R A Y H U N T E R 🐳");
|
||||
|
||||
let qmdl_store_lock = Arc::new(RwLock::new(init_qmdl_store(&config).await?));
|
||||
let (tx, rx) = mpsc::channel::<DiagDeviceCtrlMessage>(1);
|
||||
let (ui_update_tx, ui_update_rx) = mpsc::channel::<framebuffer::DisplayState>(1);
|
||||
let store = init_qmdl_store(&config).await?;
|
||||
let analysis_status = AnalysisStatus::new(&store);
|
||||
let qmdl_store_lock = Arc::new(RwLock::new(store));
|
||||
let (diag_tx, diag_rx) = mpsc::channel::<DiagDeviceCtrlMessage>(1);
|
||||
let (ui_update_tx, ui_update_rx) = mpsc::channel::<display::DisplayState>(1);
|
||||
let (analysis_tx, analysis_rx) = mpsc::channel::<AnalysisCtrlMessage>(5);
|
||||
let mut maybe_ui_shutdown_tx = None;
|
||||
if !config.debug_mode {
|
||||
let (ui_shutdown_tx, ui_shutdown_rx) = oneshot::channel();
|
||||
maybe_ui_shutdown_tx = Some(ui_shutdown_tx);
|
||||
let mut dev = DiagDevice::new().await
|
||||
let mut dev = DiagDevice::new()
|
||||
.await
|
||||
.map_err(RayhunterError::DiagInitError)?;
|
||||
dev.config_logs().await
|
||||
dev.config_logs()
|
||||
.await
|
||||
.map_err(RayhunterError::DiagInitError)?;
|
||||
|
||||
info!("Starting Diag Thread");
|
||||
run_diag_read_thread(&task_tracker, dev, rx, ui_update_tx.clone(), qmdl_store_lock.clone(), config.enable_dummy_analyzer);
|
||||
run_diag_read_thread(
|
||||
&task_tracker,
|
||||
dev,
|
||||
diag_rx,
|
||||
ui_update_tx.clone(),
|
||||
qmdl_store_lock.clone(),
|
||||
analysis_tx.clone(),
|
||||
config.enable_dummy_analyzer,
|
||||
);
|
||||
info!("Starting UI");
|
||||
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");
|
||||
key_input::run_key_input_thread(&task_tracker, &config, diag_tx.clone());
|
||||
}
|
||||
let (server_shutdown_tx, server_shutdown_rx) = oneshot::channel::<()>();
|
||||
info!("create shutdown thread");
|
||||
let analysis_status_lock = Arc::new(RwLock::new(AnalysisStatus::default()));
|
||||
run_analysis_thread(&task_tracker, analysis_rx, qmdl_store_lock.clone(), analysis_status_lock.clone(), config.enable_dummy_analyzer);
|
||||
run_ctrl_c_thread(&task_tracker, tx.clone(), server_shutdown_tx, maybe_ui_shutdown_tx, qmdl_store_lock.clone(), analysis_tx.clone());
|
||||
run_server(&task_tracker, &config, qmdl_store_lock.clone(), server_shutdown_rx, ui_update_tx, tx, analysis_tx, analysis_status_lock).await;
|
||||
let analysis_status_lock = Arc::new(RwLock::new(analysis_status));
|
||||
run_analysis_thread(
|
||||
&task_tracker,
|
||||
analysis_rx,
|
||||
qmdl_store_lock.clone(),
|
||||
analysis_status_lock.clone(),
|
||||
config.enable_dummy_analyzer,
|
||||
);
|
||||
run_ctrl_c_thread(
|
||||
&task_tracker,
|
||||
diag_tx.clone(),
|
||||
server_shutdown_tx,
|
||||
maybe_ui_shutdown_tx,
|
||||
qmdl_store_lock.clone(),
|
||||
analysis_tx.clone(),
|
||||
);
|
||||
let state = Arc::new(ServerState {
|
||||
qmdl_store_lock: qmdl_store_lock.clone(),
|
||||
diag_device_ctrl_sender: diag_tx,
|
||||
ui_update_sender: ui_update_tx,
|
||||
debug_mode: config.debug_mode,
|
||||
analysis_status_lock,
|
||||
analysis_sender: analysis_tx,
|
||||
});
|
||||
run_server(&task_tracker, &config, state, server_shutdown_rx).await;
|
||||
|
||||
task_tracker.close();
|
||||
task_tracker.wait().await;
|
||||
@@ -241,3 +240,14 @@ async fn main() -> Result<(), RayhunterError> {
|
||||
info!("see you space cowboy...");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_get_router() {
|
||||
// assert that creating the router does not panic from invalid route patterns.
|
||||
let _ = get_router();
|
||||
}
|
||||
}
|
||||
|
||||
199
bin/src/diag.rs
@@ -6,25 +6,25 @@ use axum::extract::{Path, State};
|
||||
use axum::http::header::CONTENT_TYPE;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use futures::{StreamExt, TryStreamExt};
|
||||
use log::{debug, error, info, warn};
|
||||
use rayhunter::diag::DataType;
|
||||
use rayhunter::diag_device::DiagDevice;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::sync::mpsc::{Receiver, Sender};
|
||||
use rayhunter::qmdl::QmdlWriter;
|
||||
use log::{debug, error, info};
|
||||
use tokio::fs::File;
|
||||
use tokio::sync::mpsc::{Receiver, Sender};
|
||||
use tokio::sync::RwLock;
|
||||
use tokio_util::io::ReaderStream;
|
||||
use tokio_util::task::TaskTracker;
|
||||
use futures::{StreamExt, TryStreamExt};
|
||||
|
||||
use crate::framebuffer;
|
||||
use crate::qmdl_store::RecordingStore;
|
||||
use crate::analysis::{AnalysisCtrlMessage, AnalysisWriter};
|
||||
use crate::display;
|
||||
use crate::qmdl_store::{RecordingStore, RecordingStoreError};
|
||||
use crate::server::ServerState;
|
||||
use crate::analysis::AnalysisWriter;
|
||||
|
||||
pub enum DiagDeviceCtrlMessage {
|
||||
StopRecording,
|
||||
StartRecording((QmdlWriter<File>, File)),
|
||||
StartRecording,
|
||||
Exit,
|
||||
}
|
||||
|
||||
@@ -32,8 +32,9 @@ pub fn run_diag_read_thread(
|
||||
task_tracker: &TaskTracker,
|
||||
mut dev: DiagDevice,
|
||||
mut qmdl_file_rx: Receiver<DiagDeviceCtrlMessage>,
|
||||
ui_update_sender: Sender<framebuffer::DisplayState>,
|
||||
ui_update_sender: Sender<display::DisplayState>,
|
||||
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
|
||||
analysis_sender: Sender<AnalysisCtrlMessage>,
|
||||
enable_dummy_analyzer: bool,
|
||||
) {
|
||||
task_tracker.spawn(async move {
|
||||
@@ -46,20 +47,56 @@ pub fn run_diag_read_thread(
|
||||
tokio::select! {
|
||||
msg = qmdl_file_rx.recv() => {
|
||||
match msg {
|
||||
Some(DiagDeviceCtrlMessage::StartRecording((new_writer, new_analysis_file))) => {
|
||||
maybe_qmdl_writer = Some(new_writer);
|
||||
Some(DiagDeviceCtrlMessage::StartRecording) => {
|
||||
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||
let (qmdl_file, new_analysis_file) = match qmdl_store.new_entry().await {
|
||||
Ok(x) => x,
|
||||
Err(e) => {
|
||||
error!("couldn't create new qmdl entry: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
maybe_qmdl_writer = Some(QmdlWriter::new(qmdl_file));
|
||||
|
||||
if let Some(analysis_writer) = maybe_analysis_writer {
|
||||
analysis_writer.close().await.expect("failed to close analysis writer");
|
||||
}
|
||||
|
||||
maybe_analysis_writer = Some(AnalysisWriter::new(new_analysis_file, enable_dummy_analyzer).await
|
||||
.expect("failed to write to analysis file"));
|
||||
|
||||
if let Err(e) = ui_update_sender.send(display::DisplayState::Recording).await {
|
||||
warn!("couldn't send ui update message: {}", e);
|
||||
}
|
||||
},
|
||||
Some(DiagDeviceCtrlMessage::StopRecording) => {
|
||||
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||
match qmdl_store.get_current_entry() {
|
||||
Some((_, entry)) => {
|
||||
if let Err(e) = analysis_sender
|
||||
.send(AnalysisCtrlMessage::RecordingFinished(
|
||||
entry.name.to_string(),
|
||||
))
|
||||
.await {
|
||||
warn!("couldn't send analysis message: {}", e);
|
||||
}
|
||||
}
|
||||
None => todo!(),
|
||||
}
|
||||
if let Err(e) = qmdl_store.close_current_entry().await {
|
||||
error!("couldn't close current entry: {}", e);
|
||||
}
|
||||
|
||||
maybe_qmdl_writer = None;
|
||||
if let Some(analysis_writer) = maybe_analysis_writer {
|
||||
analysis_writer.close().await.expect("failed to close analysis writer");
|
||||
}
|
||||
maybe_analysis_writer = None;
|
||||
|
||||
if let Err(e) = ui_update_sender.send(display::DisplayState::Paused).await {
|
||||
warn!("couldn't send ui update message: {}", e);
|
||||
}
|
||||
},
|
||||
// None means all the Senders have been dropped, so it's
|
||||
// time to go
|
||||
@@ -99,12 +136,12 @@ pub fn run_diag_read_thread(
|
||||
let (analysis_file_len, heuristic_warning) = analysis_output;
|
||||
if heuristic_warning {
|
||||
info!("a heuristic triggered on this run!");
|
||||
ui_update_sender.send(framebuffer::DisplayState::WarningDetected).await
|
||||
ui_update_sender.send(display::DisplayState::WarningDetected).await
|
||||
.expect("couldn't send ui update message: {}");
|
||||
}
|
||||
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||
let index = qmdl_store.current_entry.expect("DiagDevice had qmdl_writer, but QmdlStore didn't have current entry???");
|
||||
qmdl_store.update_entry_analysis_size(index, analysis_file_len as usize).await
|
||||
qmdl_store.update_entry_analysis_size(index, analysis_file_len).await
|
||||
.expect("failed to update analysis file size");
|
||||
}
|
||||
},
|
||||
@@ -119,49 +156,147 @@ pub fn run_diag_read_thread(
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn start_recording(State(state): State<Arc<ServerState>>) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||
pub async fn start_recording(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||
if state.debug_mode {
|
||||
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
|
||||
}
|
||||
|
||||
state
|
||||
.diag_device_ctrl_sender
|
||||
.send(DiagDeviceCtrlMessage::StartRecording)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("couldn't send start recording message: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok((StatusCode::ACCEPTED, "ok".to_string()))
|
||||
}
|
||||
|
||||
pub async fn stop_recording(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||
if state.debug_mode {
|
||||
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
|
||||
}
|
||||
state
|
||||
.diag_device_ctrl_sender
|
||||
.send(DiagDeviceCtrlMessage::StopRecording)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("couldn't send stop recording message: {}", e),
|
||||
)
|
||||
})?;
|
||||
Ok((StatusCode::ACCEPTED, "ok".to_string()))
|
||||
}
|
||||
|
||||
pub async fn delete_recording(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
Path(qmdl_name): Path<String>,
|
||||
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||
if state.debug_mode {
|
||||
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
|
||||
}
|
||||
let mut qmdl_store = state.qmdl_store_lock.write().await;
|
||||
let (qmdl_file, analysis_file) = qmdl_store.new_entry().await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("couldn't create new qmdl entry: {}", e)))?;
|
||||
let qmdl_writer = QmdlWriter::new(qmdl_file);
|
||||
state.diag_device_ctrl_sender.send(DiagDeviceCtrlMessage::StartRecording((qmdl_writer, analysis_file))).await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("couldn't send stop recording message: {}", e)))?;
|
||||
state.ui_update_sender.send(framebuffer::DisplayState::Recording).await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("couldn't send ui update message: {}", e)))?;
|
||||
match qmdl_store.delete_entry(&qmdl_name).await {
|
||||
Err(RecordingStoreError::NoSuchEntryError) => {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("no recording with name {qmdl_name}"),
|
||||
))
|
||||
}
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("couldn't delete recording: {e}"),
|
||||
))
|
||||
}
|
||||
Ok(_) => {}
|
||||
}
|
||||
state
|
||||
.diag_device_ctrl_sender
|
||||
.send(DiagDeviceCtrlMessage::StopRecording)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("couldn't send stop recording message: {}", e),
|
||||
)
|
||||
})?;
|
||||
state
|
||||
.ui_update_sender
|
||||
.send(display::DisplayState::Paused)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("couldn't send ui update message: {}", e),
|
||||
)
|
||||
})?;
|
||||
Ok((StatusCode::ACCEPTED, "ok".to_string()))
|
||||
}
|
||||
|
||||
pub async fn stop_recording(State(state): State<Arc<ServerState>>) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||
pub async fn delete_all_recordings(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||
if state.debug_mode {
|
||||
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
|
||||
}
|
||||
state
|
||||
.diag_device_ctrl_sender
|
||||
.send(DiagDeviceCtrlMessage::StopRecording)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("couldn't send stop recording message: {}", e),
|
||||
)
|
||||
})?;
|
||||
let mut qmdl_store = state.qmdl_store_lock.write().await;
|
||||
qmdl_store.close_current_entry().await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("couldn't close current qmdl entry: {}", e)))?;
|
||||
state.diag_device_ctrl_sender.send(DiagDeviceCtrlMessage::StopRecording).await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("couldn't send stop recording message: {}", e)))?;
|
||||
state.ui_update_sender.send(framebuffer::DisplayState::Paused).await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("couldn't send ui update message: {}", e)))?;
|
||||
qmdl_store.delete_all_entries().await.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("couldn't delete all recordings: {}", e),
|
||||
)
|
||||
})?;
|
||||
state
|
||||
.ui_update_sender
|
||||
.send(display::DisplayState::Paused)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("couldn't send ui update message: {}", e),
|
||||
)
|
||||
})?;
|
||||
Ok((StatusCode::ACCEPTED, "ok".to_string()))
|
||||
}
|
||||
|
||||
pub async fn get_analysis_report(State(state): State<Arc<ServerState>>, Path(qmdl_name): Path<String>) -> Result<Response, (StatusCode, String)> {
|
||||
pub async fn get_analysis_report(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
Path(qmdl_name): Path<String>,
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
let qmdl_store = state.qmdl_store_lock.read().await;
|
||||
let (entry_index, _) = if qmdl_name == "live" {
|
||||
qmdl_store.get_current_entry().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"No QMDL data's being recorded to analyze, try starting a new recording!".to_string()
|
||||
"No QMDL data's being recorded to analyze, try starting a new recording!".to_string(),
|
||||
))?
|
||||
} else {
|
||||
qmdl_store.entry_for_name(&qmdl_name).ok_or((
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("Couldn't find QMDL entry with name \"{}\"", qmdl_name)
|
||||
format!("Couldn't find QMDL entry with name \"{}\"", qmdl_name),
|
||||
))?
|
||||
};
|
||||
let analysis_file = qmdl_store.open_entry_analysis(entry_index).await
|
||||
let analysis_file = qmdl_store
|
||||
.open_entry_analysis(entry_index)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)))?;
|
||||
let analysis_stream = ReaderStream::new(analysis_file);
|
||||
|
||||
|
||||
202
bin/src/display/generic_framebuffer.rs
Normal file
@@ -0,0 +1,202 @@
|
||||
use image::{codecs::gif::GifDecoder, imageops::FilterType, AnimationDecoder, DynamicImage};
|
||||
use std::io::Cursor;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config;
|
||||
use crate::display::DisplayState;
|
||||
|
||||
use log::{error, info};
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::sync::oneshot::error::TryRecvError;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
use std::thread::sleep;
|
||||
|
||||
use include_dir::{include_dir, Dir};
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct Dimensions {
|
||||
pub height: u32,
|
||||
pub width: u32,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Copy, Clone)]
|
||||
pub enum Color {
|
||||
Red,
|
||||
Green,
|
||||
Blue,
|
||||
White,
|
||||
Black,
|
||||
Cyan,
|
||||
Yellow,
|
||||
Pink,
|
||||
}
|
||||
|
||||
impl Color {
|
||||
fn rgb(self) -> (u8, u8, u8) {
|
||||
match self {
|
||||
Color::Red => (0xff, 0, 0),
|
||||
Color::Green => (0, 0xff, 0),
|
||||
Color::Blue => (0, 0, 0xff),
|
||||
Color::White => (0xff, 0xff, 0xff),
|
||||
Color::Black => (0, 0, 0),
|
||||
Color::Cyan => (0, 0xff, 0xff),
|
||||
Color::Yellow => (0xff, 0xff, 0),
|
||||
Color::Pink => (0xfe, 0x24, 0xff),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Color {
|
||||
fn from_state(state: DisplayState, colorblind_mode: bool) -> Self {
|
||||
match state {
|
||||
DisplayState::Paused => Color::White,
|
||||
DisplayState::Recording => {
|
||||
if colorblind_mode {
|
||||
Color::Blue
|
||||
} else {
|
||||
Color::Green
|
||||
}
|
||||
}
|
||||
DisplayState::WarningDetected => Color::Red,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait GenericFramebuffer: Send + 'static {
|
||||
fn dimensions(&self) -> Dimensions;
|
||||
|
||||
fn write_buffer(
|
||||
&mut self,
|
||||
buffer: &[(u8, u8, u8)], // rgb, row-wise, left-to-right, top-to-bottom
|
||||
);
|
||||
|
||||
fn write_dynamic_image(&mut self, img: DynamicImage) {
|
||||
let dimensions = self.dimensions();
|
||||
let mut width = img.width();
|
||||
let mut height = img.height();
|
||||
let resized_img: DynamicImage;
|
||||
if height > dimensions.height || width > dimensions.width {
|
||||
resized_img = img.resize(dimensions.width, dimensions.height, FilterType::CatmullRom);
|
||||
width = dimensions.width.min(resized_img.width());
|
||||
height = dimensions.height.min(resized_img.height());
|
||||
} else {
|
||||
resized_img = img;
|
||||
}
|
||||
let img_rgba8 = resized_img.as_rgba8().unwrap();
|
||||
let mut buf = Vec::new();
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let px = img_rgba8.get_pixel(x, y);
|
||||
buf.push((px[0], px[1], px[2]));
|
||||
}
|
||||
}
|
||||
|
||||
self.write_buffer(&buf);
|
||||
}
|
||||
|
||||
fn draw_gif(&mut self, img_buffer: &[u8]) {
|
||||
// this is dumb and i'm sure there's a better way to loop this
|
||||
let cursor = Cursor::new(img_buffer);
|
||||
let decoder = GifDecoder::new(cursor).unwrap();
|
||||
for maybe_frame in decoder.into_frames() {
|
||||
let frame = maybe_frame.unwrap();
|
||||
let (numerator, _) = frame.delay().numer_denom_ms();
|
||||
let img = DynamicImage::from(frame.into_buffer());
|
||||
self.write_dynamic_image(img);
|
||||
std::thread::sleep(Duration::from_millis(numerator as u64));
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_img(&mut self, img_buffer: &[u8]) {
|
||||
let img = image::load_from_memory(img_buffer).unwrap();
|
||||
self.write_dynamic_image(img);
|
||||
}
|
||||
|
||||
fn draw_line(&mut self, color: Color, height: u32) {
|
||||
let width = self.dimensions().width;
|
||||
let px_num = height * width;
|
||||
let mut buffer = Vec::new();
|
||||
for _ in 0..px_num {
|
||||
buffer.push(color.rgb());
|
||||
}
|
||||
|
||||
self.write_buffer(&buffer);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_ui(
|
||||
task_tracker: &TaskTracker,
|
||||
config: &config::Config,
|
||||
mut fb: impl GenericFramebuffer,
|
||||
mut ui_shutdown_rx: oneshot::Receiver<()>,
|
||||
mut ui_update_rx: Receiver<DisplayState>,
|
||||
) {
|
||||
static IMAGE_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/images/");
|
||||
let display_level = config.ui_level;
|
||||
if display_level == 0 {
|
||||
info!("Invisible mode, not spawning UI.");
|
||||
}
|
||||
|
||||
let colorblind_mode = config.colorblind_mode;
|
||||
let mut display_color = Color::from_state(DisplayState::Recording, colorblind_mode);
|
||||
|
||||
task_tracker.spawn_blocking(move || {
|
||||
// this feels wrong, is there a more rusty way to do this?
|
||||
let mut img: Option<&[u8]> = None;
|
||||
if display_level == 2 {
|
||||
img = Some(
|
||||
IMAGE_DIR
|
||||
.get_file("orca.gif")
|
||||
.expect("failed to read orca.gif")
|
||||
.contents(),
|
||||
);
|
||||
} else if display_level == 3 {
|
||||
img = Some(
|
||||
IMAGE_DIR
|
||||
.get_file("eff.png")
|
||||
.expect("failed to read eff.png")
|
||||
.contents(),
|
||||
);
|
||||
}
|
||||
loop {
|
||||
match ui_shutdown_rx.try_recv() {
|
||||
Ok(_) => {
|
||||
info!("received UI shutdown");
|
||||
break;
|
||||
}
|
||||
Err(TryRecvError::Empty) => {}
|
||||
Err(e) => panic!("error receiving shutdown message: {e}"),
|
||||
}
|
||||
match ui_update_rx.try_recv() {
|
||||
Ok(state) => {
|
||||
display_color = Color::from_state(state, colorblind_mode);
|
||||
}
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
|
||||
Err(e) => error!("error receiving framebuffer update message: {e}"),
|
||||
}
|
||||
|
||||
match display_level {
|
||||
2 => {
|
||||
fb.draw_gif(img.unwrap());
|
||||
}
|
||||
3 => fb.draw_img(img.unwrap()),
|
||||
128 => {
|
||||
fb.draw_line(Color::Cyan, 128);
|
||||
fb.draw_line(Color::Pink, 102);
|
||||
fb.draw_line(Color::White, 76);
|
||||
fb.draw_line(Color::Pink, 50);
|
||||
fb.draw_line(Color::Cyan, 25);
|
||||
}
|
||||
_ => {
|
||||
// this branch id for ui_level 1, which is also the default if an
|
||||
// unknown value is used
|
||||
fb.draw_line(display_color, 2);
|
||||
}
|
||||
};
|
||||
sleep(Duration::from_millis(1000));
|
||||
}
|
||||
});
|
||||
}
|
||||
28
bin/src/display/mod.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
mod generic_framebuffer;
|
||||
|
||||
#[cfg(feature = "tplink")]
|
||||
mod tplink;
|
||||
#[cfg(feature = "tplink")]
|
||||
mod tplink_framebuffer;
|
||||
#[cfg(feature = "tplink")]
|
||||
mod tplink_onebit;
|
||||
|
||||
#[cfg(feature = "tplink")]
|
||||
pub use tplink::update_ui;
|
||||
|
||||
#[cfg(feature = "orbic")]
|
||||
mod orbic;
|
||||
#[cfg(feature = "orbic")]
|
||||
pub use orbic::update_ui;
|
||||
|
||||
pub enum DisplayState {
|
||||
Recording,
|
||||
Paused,
|
||||
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");
|
||||
49
bin/src/display/orbic.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
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 {
|
||||
// TODO actually poll for this, maybe w/ fbset?
|
||||
Dimensions {
|
||||
height: 128,
|
||||
width: 128,
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
29
bin/src/display/tplink.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use log::info;
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
use crate::config;
|
||||
use crate::display::{tplink_framebuffer, tplink_onebit, DisplayState};
|
||||
|
||||
use std::fs;
|
||||
|
||||
pub fn update_ui(
|
||||
task_tracker: &TaskTracker,
|
||||
config: &config::Config,
|
||||
ui_shutdown_rx: oneshot::Receiver<()>,
|
||||
ui_update_rx: Receiver<DisplayState>,
|
||||
) {
|
||||
let display_level = config.ui_level;
|
||||
if display_level == 0 {
|
||||
info!("Invisible mode, not spawning UI.");
|
||||
}
|
||||
|
||||
if fs::exists(tplink_onebit::OLED_PATH).unwrap_or_default() {
|
||||
info!("detected one-bit display");
|
||||
tplink_onebit::update_ui(task_tracker, config, ui_shutdown_rx, ui_update_rx)
|
||||
} else {
|
||||
info!("fallback to framebuffer");
|
||||
tplink_framebuffer::update_ui(task_tracker, config, ui_shutdown_rx, ui_update_rx)
|
||||
}
|
||||
}
|
||||
90
bin/src/display/tplink_framebuffer.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::os::fd::AsRawFd;
|
||||
|
||||
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";
|
||||
|
||||
struct Framebuffer;
|
||||
|
||||
#[repr(C)]
|
||||
struct fb_fillrect {
|
||||
dx: u32,
|
||||
dy: u32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
color: u32,
|
||||
rop: u32,
|
||||
}
|
||||
|
||||
impl GenericFramebuffer for Framebuffer {
|
||||
fn dimensions(&self) -> Dimensions {
|
||||
// TODO actually poll for this, maybe w/ fbset?
|
||||
Dimensions {
|
||||
height: 128,
|
||||
width: 128,
|
||||
}
|
||||
}
|
||||
|
||||
fn write_buffer(&mut self, buffer: &[(u8, u8, u8)]) {
|
||||
// for how to write to the buffer, consult M7350v5_en_gpl/bootable/recovery/recovery_color_oled.c
|
||||
let dimensions = self.dimensions();
|
||||
let width = dimensions.width;
|
||||
let height = buffer.len() as u32 / width;
|
||||
let mut f = File::options().write(true).open(FB_PATH).unwrap();
|
||||
let mut arg = fb_fillrect {
|
||||
dx: 0,
|
||||
dy: 0,
|
||||
width,
|
||||
height,
|
||||
color: 0xffff, // not sure what this is
|
||||
rop: 0,
|
||||
};
|
||||
|
||||
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;
|
||||
// note: big-endian!
|
||||
raw_buffer.extend(rgb565.to_be_bytes());
|
||||
}
|
||||
|
||||
f.write_all(&raw_buffer).unwrap();
|
||||
|
||||
unsafe {
|
||||
let res = libc::ioctl(
|
||||
f.as_raw_fd(),
|
||||
0x4619, // FBIORECT_DISPLAY
|
||||
&mut arg as *mut _,
|
||||
std::mem::size_of::<fb_fillrect>(),
|
||||
);
|
||||
|
||||
if res < 0 {
|
||||
panic!("failed to send FBIORECT_DISPLAY ioctl, {}", res);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
170
bin/src/display/tplink_onebit.rs
Normal file
@@ -0,0 +1,170 @@
|
||||
/// Display module for the TP-Link M7350 oled one-bit display.
|
||||
///
|
||||
/// https://github.com/m0veax/tplink_m7350/tree/main/oled
|
||||
use crate::config;
|
||||
use crate::display::DisplayState;
|
||||
|
||||
use log::{error, info};
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::sync::oneshot::error::TryRecvError;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
use std::fs;
|
||||
use std::thread::sleep;
|
||||
use std::time::Duration;
|
||||
|
||||
pub const OLED_PATH: &str = "/sys/class/display/oled/oled_buffer";
|
||||
|
||||
// those coordinates were mainly chosen for a spot that doesn't get regularly updated by the main
|
||||
// oledd service. otherwise we'd have to write to the display more than once per second to prevent
|
||||
// the icon from flickering.
|
||||
const STATUS_X: u8 = 104;
|
||||
const STATUS_Y: u8 = 40;
|
||||
const STATUS_W: u8 = 16;
|
||||
const STATUS_H: u8 = 16;
|
||||
|
||||
macro_rules! pixel {
|
||||
(x) => {
|
||||
0
|
||||
};
|
||||
(_) => {
|
||||
1
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! pixelart {
|
||||
(x=$x:expr, y=$y:expr, width=$width:expr, height=$height:expr; $($a:tt $b:tt $c:tt $d:tt $e:tt $f:tt $g:tt $h:tt)*) => {{
|
||||
// one bit per pixel + 4 bytes for header
|
||||
const BUF_SIZE: usize = ($width as usize * $height as usize) / 8 + 4;
|
||||
const BUF_BYTES: [u8; BUF_SIZE] = [
|
||||
$x,
|
||||
$y,
|
||||
$width,
|
||||
$height,
|
||||
$(
|
||||
(pixel!($a) << 7 | pixel!($b) << 6 | pixel!($c) << 5 | pixel!($d) << 4 | pixel!($e) << 3 | pixel!($f) << 2 | pixel!($g) << 1 | pixel!($h)),
|
||||
)*
|
||||
];
|
||||
|
||||
&BUF_BYTES
|
||||
}}
|
||||
}
|
||||
|
||||
const STATUS_PAUSED: &[u8] = pixelart! {
|
||||
x=STATUS_X, y=STATUS_Y, width=STATUS_W, height=STATUS_H;
|
||||
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
|
||||
_ _ _ x x x x x x x x x x _ _ _
|
||||
_ x x _ _ _ _ _ _ _ _ _ _ x x _
|
||||
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
|
||||
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
|
||||
_ x _ _ _ x _ _ _ _ x _ _ _ x _
|
||||
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
|
||||
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
|
||||
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
|
||||
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
|
||||
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
|
||||
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
|
||||
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
|
||||
_ x x _ _ _ _ _ _ _ _ _ _ x x _
|
||||
_ _ _ x x x x x x x x x x _ _ _
|
||||
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
|
||||
};
|
||||
|
||||
const STATUS_SMILING: &[u8] = pixelart! {
|
||||
x=STATUS_X, y=STATUS_Y, width=STATUS_W, height=STATUS_H;
|
||||
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
|
||||
_ _ _ x x x x x x x x x x _ _ _
|
||||
_ x x _ _ _ _ _ _ _ _ _ _ x x _
|
||||
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
|
||||
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
|
||||
_ x _ _ _ x _ _ _ _ x _ _ _ x _
|
||||
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
|
||||
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
|
||||
_ x _ _ _ x _ _ _ _ x _ _ _ x _
|
||||
_ x _ _ _ x _ _ _ _ x _ _ _ x _
|
||||
_ x _ _ _ x x x x x x _ _ _ x _
|
||||
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
|
||||
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
|
||||
_ x x _ _ _ _ _ _ _ _ _ _ x x _
|
||||
_ _ _ x x x x x x x x x x _ _ _
|
||||
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
|
||||
};
|
||||
|
||||
const STATUS_WARNING: &[u8] = pixelart! {
|
||||
x=STATUS_X, y=STATUS_Y, width=STATUS_W, height=STATUS_H;
|
||||
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
|
||||
_ _ _ x x x x x x x x x x _ _ _
|
||||
_ x x _ _ _ _ _ _ _ _ _ _ x x _
|
||||
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
|
||||
_ x _ _ _ _ _ x x _ _ _ _ _ x _
|
||||
_ x _ _ _ _ _ x x _ _ _ _ _ x _
|
||||
_ x _ _ _ _ _ x x _ _ _ _ _ x _
|
||||
_ x _ _ _ _ _ x x _ _ _ _ _ x _
|
||||
_ x _ _ _ _ _ x x _ _ _ _ _ x _
|
||||
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
|
||||
_ x _ _ _ _ _ x x _ _ _ _ _ x _
|
||||
_ x _ _ _ _ _ x x _ _ _ _ _ x _
|
||||
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
|
||||
_ x x _ _ _ _ _ _ _ _ _ _ x x _
|
||||
_ _ _ x x x x x x x x x x _ _ _
|
||||
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
|
||||
};
|
||||
|
||||
pub fn update_ui(
|
||||
task_tracker: &TaskTracker,
|
||||
config: &config::Config,
|
||||
mut ui_shutdown_rx: oneshot::Receiver<()>,
|
||||
mut ui_update_rx: Receiver<DisplayState>,
|
||||
) {
|
||||
let display_level = config.ui_level;
|
||||
if display_level == 0 {
|
||||
info!("Invisible mode, not spawning UI.");
|
||||
}
|
||||
|
||||
task_tracker.spawn_blocking(move || {
|
||||
let mut pixels = STATUS_SMILING;
|
||||
|
||||
loop {
|
||||
match ui_shutdown_rx.try_recv() {
|
||||
Ok(_) => {
|
||||
info!("received UI shutdown");
|
||||
break;
|
||||
}
|
||||
Err(TryRecvError::Empty) => {}
|
||||
Err(e) => panic!("error receiving shutdown message: {e}"),
|
||||
}
|
||||
|
||||
match ui_update_rx.try_recv() {
|
||||
Ok(DisplayState::Paused) => pixels = STATUS_PAUSED,
|
||||
Ok(DisplayState::Recording) => pixels = STATUS_SMILING,
|
||||
Ok(DisplayState::WarningDetected) => pixels = STATUS_WARNING,
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
|
||||
Err(e) => {
|
||||
error!("error receiving framebuffer update message: {e}");
|
||||
}
|
||||
};
|
||||
|
||||
// we write the status every second because it may have been overwritten through menu
|
||||
// navigation.
|
||||
if display_level != 0 {
|
||||
if let Err(e) = fs::write(OLED_PATH, &pixels) {
|
||||
error!("failed to write to display: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
sleep(Duration::from_millis(1000));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pixelart_macro() {
|
||||
assert_eq!(
|
||||
STATUS_WARNING,
|
||||
[
|
||||
104, 40, 16, 16, 255, 255, 224, 7, 159, 249, 191, 253, 190, 125, 190, 125, 190, 125,
|
||||
190, 125, 190, 125, 191, 253, 190, 125, 190, 125, 191, 253, 159, 249, 224, 7, 255, 255
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -5,11 +5,11 @@ use rayhunter::telcom_parser::lte_rrc::{PCCH_MessageType, PCCH_MessageType_c1, P
|
||||
use rayhunter::analysis::analyzer::{Analyzer, Event, EventType, Severity};
|
||||
use rayhunter::analysis::information_element::{InformationElement, LteInformationElement};
|
||||
|
||||
pub struct TestAnalyzer{
|
||||
pub struct TestAnalyzer {
|
||||
pub count: i32,
|
||||
}
|
||||
|
||||
impl Analyzer for TestAnalyzer{
|
||||
impl Analyzer for TestAnalyzer {
|
||||
fn get_name(&self) -> Cow<str> {
|
||||
Cow::from("Example Analyzer")
|
||||
}
|
||||
@@ -22,12 +22,16 @@ impl Analyzer for TestAnalyzer{
|
||||
self.count += 1;
|
||||
if self.count % 100 == 0 {
|
||||
return Some(Event {
|
||||
event_type: EventType::Informational ,
|
||||
event_type: EventType::Informational,
|
||||
message: "multiple of 100 events processed".to_string(),
|
||||
})
|
||||
});
|
||||
}
|
||||
let InformationElement::LTE(LteInformationElement::PCCH(pcch_msg)) = ie else {
|
||||
return None;
|
||||
let pcch_msg = match ie {
|
||||
InformationElement::LTE(lte_ie) => match &**lte_ie {
|
||||
LteInformationElement::PCCH(pcch_msg) => pcch_msg,
|
||||
_ => return None,
|
||||
},
|
||||
_ => return None,
|
||||
};
|
||||
let PCCH_MessageType::C1(PCCH_MessageType_c1::Paging(paging)) = &pcch_msg.message else {
|
||||
return None;
|
||||
@@ -35,9 +39,11 @@ impl Analyzer for TestAnalyzer{
|
||||
for record in &paging.paging_record_list.as_ref()?.0 {
|
||||
if let PagingUE_Identity::S_TMSI(_) = record.ue_identity {
|
||||
return Some(Event {
|
||||
event_type: EventType::QualitativeWarning { severity: Severity::Low },
|
||||
event_type: EventType::QualitativeWarning {
|
||||
severity: Severity::Low,
|
||||
},
|
||||
message: "TMSI was provided to cell".to_string(),
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use thiserror::Error;
|
||||
use rayhunter::diag_device::DiagDeviceError;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::qmdl_store::RecordingStoreError;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum RayhunterError{
|
||||
pub enum RayhunterError {
|
||||
#[error("Config file parsing error: {0}")]
|
||||
ConfigFileParsingError(#[from] toml::de::Error),
|
||||
#[error("Diag intialization error: {0}")]
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
use image::{codecs::gif::GifDecoder, imageops::FilterType, AnimationDecoder, DynamicImage};
|
||||
use std::{io::Cursor, time::Duration};
|
||||
|
||||
const FB_PATH:&str = "/dev/fb0";
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
// TODO actually poll for this, maybe w/ fbset?
|
||||
struct Dimensions {
|
||||
height: u32,
|
||||
width: u32,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Copy, Clone)]
|
||||
pub enum Color565 {
|
||||
Red = 0b1111100000000000,
|
||||
Green = 0b0000011111100000,
|
||||
Blue = 0b0000000000011111,
|
||||
White = 0b1111111111111111,
|
||||
Black = 0b0000000000000000,
|
||||
Cyan = 0b0000011111111111,
|
||||
Yellow = 0b1111111111100000,
|
||||
Pink = 0b1111010010011111,
|
||||
}
|
||||
|
||||
pub enum DisplayState {
|
||||
Recording,
|
||||
Paused,
|
||||
WarningDetected,
|
||||
}
|
||||
|
||||
impl From<DisplayState> for Color565 {
|
||||
fn from(state: DisplayState) -> Self {
|
||||
match state {
|
||||
DisplayState::Paused => Color565::White,
|
||||
DisplayState::Recording => Color565::Green,
|
||||
DisplayState::WarningDetected => Color565::Red,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct Framebuffer<'a> {
|
||||
dimensions: Dimensions,
|
||||
path: &'a str,
|
||||
}
|
||||
|
||||
impl Framebuffer<'_>{
|
||||
pub const fn new() -> Self {
|
||||
Framebuffer{
|
||||
dimensions: Dimensions{height: 128, width: 128},
|
||||
path: FB_PATH,
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&mut self, img: DynamicImage) {
|
||||
let mut width = img.width();
|
||||
let mut height = img.height();
|
||||
let resized_img: DynamicImage;
|
||||
if height > self.dimensions.height ||
|
||||
width > self.dimensions.width {
|
||||
resized_img = img.resize( self.dimensions.width, self.dimensions.height, FilterType::CatmullRom);
|
||||
width = self.dimensions.width.min(resized_img.width());
|
||||
height = self.dimensions.height.min(resized_img.height());
|
||||
} else {
|
||||
resized_img = img;
|
||||
}
|
||||
let img_rgba8 = resized_img.as_rgba8().unwrap();
|
||||
let mut buf = Vec::new();
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let px = img_rgba8.get_pixel(x, y);
|
||||
let mut rgb565: u16 = (px[0] as u16 & 0b11111000) << 8;
|
||||
rgb565 |= (px[1] as u16 & 0b11111100) << 3;
|
||||
rgb565 |= (px[2] as u16) >> 3;
|
||||
buf.extend(rgb565.to_le_bytes());
|
||||
}
|
||||
}
|
||||
std::fs::write(self.path, &buf).unwrap();
|
||||
}
|
||||
|
||||
pub fn draw_gif(&mut self, img_buffer: &[u8]) {
|
||||
// this is dumb and i'm sure there's a better way to loop this
|
||||
let cursor = Cursor::new(img_buffer);
|
||||
let decoder = GifDecoder::new(cursor).unwrap();
|
||||
for maybe_frame in decoder.into_frames() {
|
||||
let frame = maybe_frame.unwrap();
|
||||
let (numerator, _) = frame.delay().numer_denom_ms();
|
||||
let img = DynamicImage::from(frame.into_buffer());
|
||||
self.write(img);
|
||||
std::thread::sleep(Duration::from_millis(numerator as u64));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw_img(&mut self, img_buffer: &[u8]) {
|
||||
let img = image::load_from_memory(img_buffer).unwrap();
|
||||
self.write(img);
|
||||
}
|
||||
|
||||
pub fn draw_line(&mut self, color: Color565, height: u32){
|
||||
let px_num= height * self.dimensions.width;
|
||||
let color: u16 = color as u16;
|
||||
let mut buffer: Vec<u8> = Vec::new();
|
||||
for _ in 0..px_num {
|
||||
buffer.extend(color.to_le_bytes());
|
||||
}
|
||||
std::fs::write(self.path, &buffer).unwrap();
|
||||
}
|
||||
}
|
||||
100
bin/src/key_input.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
use log::error;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::fs::File;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::sync::mpsc::Sender;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
use crate::config;
|
||||
use crate::diag::DiagDeviceCtrlMessage;
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Event {
|
||||
KeyDown,
|
||||
KeyUp,
|
||||
}
|
||||
|
||||
const INPUT_EVENT_SIZE: usize = 32;
|
||||
|
||||
pub fn run_key_input_thread(
|
||||
task_tracker: &TaskTracker,
|
||||
config: &config::Config,
|
||||
diag_tx: Sender<DiagDeviceCtrlMessage>,
|
||||
) {
|
||||
if config.key_input_mode == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
task_tracker.spawn(async move {
|
||||
// Open the input device
|
||||
let mut file = match File::open("/dev/input/event0").await {
|
||||
Ok(file) => file,
|
||||
Err(e) => {
|
||||
error!("Failed to open /dev/input/event0: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut buffer = [0u8; INPUT_EVENT_SIZE];
|
||||
let mut last_keyup: Option<Instant> = None;
|
||||
|
||||
loop {
|
||||
if let Err(e) = file.read_exact(&mut buffer).await {
|
||||
error!("failed to read key input: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
let event = parse_event(buffer);
|
||||
|
||||
match event {
|
||||
Event::KeyUp => {
|
||||
if last_keyup.is_some()
|
||||
&& last_keyup.unwrap().elapsed() < Duration::from_millis(500)
|
||||
{
|
||||
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;
|
||||
} else {
|
||||
last_keyup = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
Event::KeyDown => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn parse_event(input: [u8; INPUT_EVENT_SIZE]) -> Event {
|
||||
if input[12] == 0 {
|
||||
Event::KeyUp
|
||||
} else {
|
||||
Event::KeyDown
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_event_keydown_m7350_v5() {
|
||||
let input = [
|
||||
0x57, 0x6c, 0x09, 0x00, 0x7c, 0xfb, 0x03, 0x00, 0x01, 0x00, 0x74, 0x00, 0x01, 0x00,
|
||||
0x00, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
];
|
||||
assert!(matches!(parse_event(input), Event::KeyDown));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_event_keyup_m7350_v5() {
|
||||
let input = [
|
||||
0x57, 0x6c, 0x09, 0x00, 0x1b, 0x15, 0x05, 0x00, 0x01, 0x00, 0x74, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
];
|
||||
assert!(matches!(parse_event(input), Event::KeyUp));
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,43 @@
|
||||
use crate::ServerState;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::header::CONTENT_TYPE;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use futures::TryStreamExt;
|
||||
use log::error;
|
||||
use rayhunter::diag::DataType;
|
||||
use rayhunter::gsmtap_parser;
|
||||
use rayhunter::pcap::GsmtapPcapWriter;
|
||||
use rayhunter::qmdl::QmdlReader;
|
||||
use axum::body::Body;
|
||||
use axum::http::header::CONTENT_TYPE;
|
||||
use axum::extract::{State, Path};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{Response, IntoResponse};
|
||||
use std::sync::Arc;
|
||||
use std::{future, pin::pin};
|
||||
use tokio::io::duplex;
|
||||
use tokio_util::io::ReaderStream;
|
||||
use std::{future, pin::pin};
|
||||
use std::sync::Arc;
|
||||
use log::error;
|
||||
use futures::TryStreamExt;
|
||||
|
||||
// Streams a pcap file chunk-by-chunk to the client by reading the QMDL data
|
||||
// written so far. This is done by spawning a thread which streams chunks of
|
||||
// pcap data to a channel that's piped to the client.
|
||||
pub async fn get_pcap(State(state): State<Arc<ServerState>>, Path(qmdl_name): Path<String>) -> Result<Response, (StatusCode, String)> {
|
||||
pub async fn get_pcap(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
Path(qmdl_name): Path<String>,
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
let qmdl_store = state.qmdl_store_lock.read().await;
|
||||
let (entry_index, entry) = qmdl_store.entry_for_name(&qmdl_name)
|
||||
.ok_or((StatusCode::NOT_FOUND, format!("couldn't find qmdl file with name {}", qmdl_name)))?;
|
||||
let (entry_index, entry) = qmdl_store.entry_for_name(&qmdl_name).ok_or((
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("couldn't find qmdl file with name {}", qmdl_name),
|
||||
))?;
|
||||
if entry.qmdl_size_bytes == 0 {
|
||||
return Err((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"QMDL file is empty, try again in a bit!".to_string()
|
||||
"QMDL file is empty, try again in a bit!".to_string(),
|
||||
));
|
||||
}
|
||||
let qmdl_size_bytes = entry.qmdl_size_bytes;
|
||||
let qmdl_file = qmdl_store.open_entry_qmdl(entry_index).await
|
||||
let qmdl_file = qmdl_store
|
||||
.open_entry_qmdl(entry_index)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)))?;
|
||||
// the QMDL reader should stop at the last successfully written data chunk
|
||||
// (entry.size_bytes)
|
||||
@@ -40,20 +47,27 @@ pub async fn get_pcap(State(state): State<Arc<ServerState>>, Path(qmdl_name): Pa
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut reader = QmdlReader::new(qmdl_file, Some(qmdl_size_bytes));
|
||||
let mut messages_stream = pin!(reader.as_stream()
|
||||
let mut messages_stream = pin!(reader
|
||||
.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") {
|
||||
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");
|
||||
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
|
||||
pcap_writer
|
||||
.write_gsmtap_message(gsmtap_msg, timestamp)
|
||||
.await
|
||||
.expect("error writing pcap packet");
|
||||
}
|
||||
},
|
||||
}
|
||||
Err(e) => error!("error parsing message: {:?}", e),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use chrono::{DateTime, Local};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{self, ErrorKind};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::{DateTime, Local};
|
||||
use rayhunter::util::RuntimeMetadata;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use tokio::{
|
||||
fs::{self, try_exists, File, OpenOptions},
|
||||
@@ -11,10 +14,14 @@ use tokio::{
|
||||
pub enum RecordingStoreError {
|
||||
#[error("Can't close an entry when there's no current entry")]
|
||||
NoCurrentEntry,
|
||||
#[error("An entry with that name doesn't exist")]
|
||||
NoSuchEntryError,
|
||||
#[error("Couldn't create file: {0}")]
|
||||
CreateFileError(tokio::io::Error),
|
||||
#[error("Couldn't read file: {0}")]
|
||||
ReadFileError(tokio::io::Error),
|
||||
#[error("Couldn't delete file: {0}")]
|
||||
DeleteFileError(tokio::io::Error),
|
||||
#[error("Couldn't open directory at path: {0}")]
|
||||
OpenDirError(tokio::io::Error),
|
||||
#[error("Couldn't read manifest file: {0}")]
|
||||
@@ -43,17 +50,24 @@ pub struct ManifestEntry {
|
||||
pub last_message_time: Option<DateTime<Local>>,
|
||||
pub qmdl_size_bytes: usize,
|
||||
pub analysis_size_bytes: usize,
|
||||
pub rayhunter_version: Option<String>,
|
||||
pub system_os: Option<String>,
|
||||
pub arch: Option<String>,
|
||||
}
|
||||
|
||||
impl ManifestEntry {
|
||||
fn new() -> Self {
|
||||
let now = Local::now();
|
||||
let metadata = RuntimeMetadata::new();
|
||||
ManifestEntry {
|
||||
name: format!("{}", now.timestamp()),
|
||||
start_time: now,
|
||||
last_message_time: None,
|
||||
qmdl_size_bytes: 0,
|
||||
analysis_size_bytes: 0,
|
||||
rayhunter_version: Some(metadata.rayhunter_version),
|
||||
system_os: Some(metadata.system_os),
|
||||
arch: Some(metadata.arch),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,23 +122,20 @@ impl RecordingStore {
|
||||
where
|
||||
P: AsRef<Path>,
|
||||
{
|
||||
let manifest_path = path.as_ref().join("manifest.toml");
|
||||
fs::create_dir_all(&path)
|
||||
.await
|
||||
.map_err(RecordingStoreError::OpenDirError)?;
|
||||
let mut manifest_file = File::create(&manifest_path)
|
||||
.await
|
||||
.map_err(RecordingStoreError::WriteManifestError)?;
|
||||
let empty_manifest = Manifest {
|
||||
entries: Vec::new(),
|
||||
|
||||
let mut store = RecordingStore {
|
||||
path: path.as_ref().to_owned(),
|
||||
manifest: Manifest {
|
||||
entries: Vec::new(),
|
||||
},
|
||||
current_entry: None,
|
||||
};
|
||||
let empty_manifest_contents =
|
||||
toml::to_string_pretty(&empty_manifest).expect("failed to serialize manifest");
|
||||
manifest_file
|
||||
.write_all(empty_manifest_contents.as_bytes())
|
||||
.await
|
||||
.map_err(RecordingStoreError::WriteManifestError)?;
|
||||
RecordingStore::load(path).await
|
||||
|
||||
store.write_manifest().await?;
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
async fn read_manifest<P>(path: P) -> Result<Manifest, RecordingStoreError>
|
||||
@@ -148,17 +159,11 @@ impl RecordingStore {
|
||||
}
|
||||
let new_entry = ManifestEntry::new();
|
||||
let qmdl_filepath = new_entry.get_qmdl_filepath(&self.path);
|
||||
let qmdl_file = File::options()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.open(&qmdl_filepath)
|
||||
let qmdl_file = File::create(&qmdl_filepath)
|
||||
.await
|
||||
.map_err(RecordingStoreError::CreateFileError)?;
|
||||
let analysis_filepath = new_entry.get_analysis_filepath(&self.path);
|
||||
let analysis_file = File::options()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.open(&analysis_filepath)
|
||||
let analysis_file = File::create(&analysis_filepath)
|
||||
.await
|
||||
.map_err(RecordingStoreError::CreateFileError)?;
|
||||
self.manifest.entries.push(new_entry);
|
||||
@@ -168,10 +173,7 @@ impl RecordingStore {
|
||||
}
|
||||
|
||||
// Returns the corresponding QMDL file for a given entry
|
||||
pub async fn open_entry_qmdl(
|
||||
&self,
|
||||
entry_index: usize,
|
||||
) -> Result<File, RecordingStoreError> {
|
||||
pub async fn open_entry_qmdl(&self, entry_index: usize) -> Result<File, RecordingStoreError> {
|
||||
let entry = &self.manifest.entries[entry_index];
|
||||
File::open(entry.get_qmdl_filepath(&self.path))
|
||||
.await
|
||||
@@ -200,8 +202,7 @@ impl RecordingStore {
|
||||
.open(entry.get_analysis_filepath(&self.path))
|
||||
.await
|
||||
.map_err(RecordingStoreError::ReadFileError)?;
|
||||
self.update_entry_analysis_size(entry_index, 0)
|
||||
.await?;
|
||||
self.update_entry_analysis_size(entry_index, 0).await?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
@@ -238,23 +239,29 @@ impl RecordingStore {
|
||||
}
|
||||
|
||||
async fn write_manifest(&mut self) -> Result<(), RecordingStoreError> {
|
||||
let mut manifest_file = File::options()
|
||||
.write(true)
|
||||
.open(self.path.join("manifest.toml"))
|
||||
let tmp_path = self.path.join("manifest.toml.new");
|
||||
let mut manifest_tmp_file = File::create(&tmp_path)
|
||||
.await
|
||||
.map_err(RecordingStoreError::WriteManifestError)?;
|
||||
|
||||
let manifest_contents =
|
||||
toml::to_string_pretty(&self.manifest).expect("failed to serialize manifest");
|
||||
manifest_file
|
||||
manifest_tmp_file
|
||||
.write_all(manifest_contents.as_bytes())
|
||||
.await
|
||||
.map_err(RecordingStoreError::WriteManifestError)?;
|
||||
|
||||
fs::rename(tmp_path, self.path.join("manifest.toml"))
|
||||
.await
|
||||
.map_err(RecordingStoreError::WriteManifestError)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Finds an entry by filename
|
||||
pub fn entry_for_name(&self, name: &str) -> Option<(usize, &ManifestEntry)> {
|
||||
let entry_index = self.manifest
|
||||
let entry_index = self
|
||||
.manifest
|
||||
.entries
|
||||
.iter()
|
||||
.position(|entry| entry.name == name)?;
|
||||
@@ -265,6 +272,72 @@ impl RecordingStore {
|
||||
let entry_index = self.current_entry?;
|
||||
Some((entry_index, &self.manifest.entries[entry_index]))
|
||||
}
|
||||
|
||||
pub async fn delete_entry(&mut self, name: &str) -> Result<ManifestEntry, RecordingStoreError> {
|
||||
let entry_to_delete_idx = self
|
||||
.manifest
|
||||
.entries
|
||||
.iter()
|
||||
.position(|entry| entry.name == name)
|
||||
.ok_or(RecordingStoreError::NoSuchEntryError)?;
|
||||
if let Some(current_entry) = self.current_entry {
|
||||
if current_entry == entry_to_delete_idx {
|
||||
self.close_current_entry().await?;
|
||||
} else {
|
||||
self.current_entry = Some(current_entry - 1);
|
||||
}
|
||||
}
|
||||
let entry_to_delete = self.manifest.entries.remove(entry_to_delete_idx);
|
||||
self.write_manifest().await?;
|
||||
let qmdl_filepath = entry_to_delete.get_qmdl_filepath(&self.path);
|
||||
let analysis_filepath = entry_to_delete.get_analysis_filepath(&self.path);
|
||||
remove_file_if_exists(&qmdl_filepath)
|
||||
.await
|
||||
.map_err(RecordingStoreError::DeleteFileError)?;
|
||||
remove_file_if_exists(&analysis_filepath)
|
||||
.await
|
||||
.map_err(RecordingStoreError::DeleteFileError)?;
|
||||
Ok(entry_to_delete)
|
||||
}
|
||||
|
||||
pub async fn delete_all_entries(&mut self) -> Result<(), RecordingStoreError> {
|
||||
if self.current_entry.is_some() {
|
||||
self.close_current_entry().await?;
|
||||
}
|
||||
|
||||
let mut keep = Vec::new();
|
||||
|
||||
for entry in &self.manifest.entries {
|
||||
let qmdl_filepath = entry.get_qmdl_filepath(&self.path);
|
||||
let analysis_filepath = entry.get_analysis_filepath(&self.path);
|
||||
|
||||
if let Err(e) = remove_file_if_exists(&qmdl_filepath).await {
|
||||
log::warn!("failed to remove {qmdl_filepath:?}: {e:?}");
|
||||
keep.push(true);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(e) = remove_file_if_exists(&analysis_filepath).await {
|
||||
log::warn!("failed to remove {analysis_filepath:?}: {e:?}");
|
||||
keep.push(true);
|
||||
continue;
|
||||
}
|
||||
|
||||
keep.push(false);
|
||||
}
|
||||
|
||||
let mut keep_iter = keep.into_iter();
|
||||
self.manifest.entries.retain(|_| keep_iter.next().unwrap());
|
||||
self.write_manifest().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_file_if_exists(path: &Path) -> Result<(), io::Error> {
|
||||
match tokio::fs::remove_file(path).await {
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
|
||||
res => res,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -321,6 +394,20 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_on_existing_store() {
|
||||
let dir = make_temp_dir();
|
||||
let mut store = RecordingStore::create(dir.path()).await.unwrap();
|
||||
let _ = store.new_entry().await.unwrap();
|
||||
let entry_index = store.current_entry.unwrap();
|
||||
store
|
||||
.update_entry_qmdl_size(entry_index, 1000)
|
||||
.await
|
||||
.unwrap();
|
||||
let store = RecordingStore::create(dir.path()).await.unwrap();
|
||||
assert_eq!(store.manifest.entries.len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_repeated_new_entries() {
|
||||
let dir = make_temp_dir();
|
||||
@@ -332,4 +419,20 @@ mod tests {
|
||||
assert_ne!(entry_index, new_entry_index);
|
||||
assert_eq!(store.manifest.entries.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_all_entries() {
|
||||
let dir = make_temp_dir();
|
||||
let mut store = RecordingStore::create(dir.path()).await.unwrap();
|
||||
let _ = store.new_entry().await.unwrap();
|
||||
assert!(store.current_entry.is_some());
|
||||
|
||||
store.delete_all_entries().await.unwrap();
|
||||
assert!(store.current_entry.is_none());
|
||||
|
||||
// regression test: deleting all entries should also work when there's no current
|
||||
// recording.
|
||||
store.delete_all_entries().await.unwrap();
|
||||
assert!(store.current_entry.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,80 +1,66 @@
|
||||
use axum::body::Body;
|
||||
use axum::http::header::{CONTENT_TYPE, self};
|
||||
use axum::extract::State;
|
||||
use axum::http::{StatusCode, HeaderValue};
|
||||
use axum::response::{Response, IntoResponse};
|
||||
use axum::extract::Path;
|
||||
use tokio::fs::File;
|
||||
use axum::extract::State;
|
||||
use axum::http::header::{self, CONTENT_LENGTH, CONTENT_TYPE};
|
||||
use axum::http::{HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use include_dir::{include_dir, Dir};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::sync::mpsc::Sender;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio_util::io::ReaderStream;
|
||||
use include_dir::{include_dir, Dir};
|
||||
|
||||
use crate::{framebuffer, DiagDeviceCtrlMessage};
|
||||
use crate::analysis::{AnalysisCtrlMessage, AnalysisStatus};
|
||||
use crate::qmdl_store::RecordingStore;
|
||||
use crate::{display, DiagDeviceCtrlMessage};
|
||||
|
||||
pub struct ServerState {
|
||||
pub qmdl_store_lock: Arc<RwLock<RecordingStore>>,
|
||||
pub diag_device_ctrl_sender: Sender<DiagDeviceCtrlMessage>,
|
||||
pub ui_update_sender: Sender<framebuffer::DisplayState>,
|
||||
pub ui_update_sender: Sender<display::DisplayState>,
|
||||
pub analysis_status_lock: Arc<RwLock<AnalysisStatus>>,
|
||||
pub analysis_sender: Sender<AnalysisCtrlMessage>,
|
||||
pub debug_mode: bool
|
||||
pub debug_mode: bool,
|
||||
}
|
||||
|
||||
pub async fn get_qmdl(State(state): State<Arc<ServerState>>, Path(qmdl_name): Path<String>) -> Result<Response, (StatusCode, String)> {
|
||||
pub async fn get_qmdl(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
Path(qmdl_name): Path<String>,
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
let qmdl_idx = qmdl_name.trim_end_matches(".qmdl");
|
||||
let qmdl_store = state.qmdl_store_lock.read().await;
|
||||
let (entry_index, entry) = qmdl_store.entry_for_name(&qmdl_name)
|
||||
.ok_or((StatusCode::NOT_FOUND, format!("couldn't find qmdl file with name {}", qmdl_name)))?;
|
||||
let qmdl_file = qmdl_store.open_entry_qmdl(entry_index).await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("error opening QMDL file: {}", e)))?;
|
||||
let (entry_index, entry) = qmdl_store.entry_for_name(qmdl_idx).ok_or((
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("couldn't find qmdl file with name {}", qmdl_idx),
|
||||
))?;
|
||||
let qmdl_file = qmdl_store.open_entry_qmdl(entry_index).await.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("error opening QMDL file: {}", e),
|
||||
)
|
||||
})?;
|
||||
let limited_qmdl_file = qmdl_file.take(entry.qmdl_size_bytes as u64);
|
||||
let qmdl_stream = ReaderStream::new(limited_qmdl_file);
|
||||
|
||||
let headers = [(CONTENT_TYPE, "application/octet-stream")];
|
||||
let headers = [
|
||||
(CONTENT_TYPE, "application/octet-stream"),
|
||||
(CONTENT_LENGTH, &entry.qmdl_size_bytes.to_string()),
|
||||
];
|
||||
let body = Body::from_stream(qmdl_stream);
|
||||
Ok((headers, body).into_response())
|
||||
}
|
||||
|
||||
// Bundles the server's static files (html/css/js) into the binary for easy distribution
|
||||
static STATIC_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/static");
|
||||
static STATIC_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/web/build");
|
||||
|
||||
pub async fn serve_static(State(state): State<Arc<ServerState>>, Path(path): Path<String>) -> impl IntoResponse {
|
||||
pub async fn serve_static(
|
||||
State(_): State<Arc<ServerState>>,
|
||||
Path(path): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let path = path.trim_start_matches('/');
|
||||
let mime_type = mime_guess::from_path(path).first_or_text_plain();
|
||||
|
||||
// if we're in debug mode, return the files from the build directory so we
|
||||
// don't have to rebuild every time the JS/HTML change
|
||||
if state.debug_mode {
|
||||
let mut build_path = std::path::PathBuf::new();
|
||||
build_path.push("bin");
|
||||
build_path.push("static");
|
||||
for part in path.split("/") {
|
||||
build_path.push(part);
|
||||
}
|
||||
return match File::open(build_path).await {
|
||||
Ok(mut file) => {
|
||||
let mut body = String::new();
|
||||
file.read_to_string(&mut body).await.expect("failed to read file");
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_str(mime_type.as_ref()).unwrap(),
|
||||
)
|
||||
.body(Body::from(body))
|
||||
.unwrap()
|
||||
},
|
||||
Err(_) => Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
};
|
||||
}
|
||||
|
||||
match STATIC_DIR.get_file(path) {
|
||||
None => Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
|
||||
@@ -3,10 +3,11 @@ use std::sync::Arc;
|
||||
use crate::qmdl_store::ManifestEntry;
|
||||
use crate::server::ServerState;
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use log::error;
|
||||
use rayhunter::util::RuntimeMetadata;
|
||||
use serde::Serialize;
|
||||
use tokio::process::Command;
|
||||
|
||||
@@ -14,6 +15,7 @@ use tokio::process::Command;
|
||||
pub struct SystemStats {
|
||||
pub disk_stats: DiskStats,
|
||||
pub memory_stats: MemoryStats,
|
||||
pub runtime_metadata: RuntimeMetadata,
|
||||
}
|
||||
|
||||
impl SystemStats {
|
||||
@@ -21,6 +23,7 @@ impl SystemStats {
|
||||
Ok(Self {
|
||||
disk_stats: DiskStats::new(qmdl_path).await?,
|
||||
memory_stats: MemoryStats::new().await?,
|
||||
runtime_metadata: RuntimeMetadata::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -65,10 +68,16 @@ pub struct MemoryStats {
|
||||
// runs the given command and returns its stdout as a string
|
||||
async fn get_cmd_output(mut cmd: Command) -> Result<String, String> {
|
||||
let cmd_str = format!("{:?}", &cmd);
|
||||
let output = cmd.output().await
|
||||
let output = cmd
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("error running command {}: {}", &cmd_str, e))?;
|
||||
if !output.status.success() {
|
||||
return Err(format!("command {} failed with exit code {}", &cmd_str, output.status.code().unwrap()));
|
||||
return Err(format!(
|
||||
"command {} failed with exit code {}",
|
||||
&cmd_str,
|
||||
output.status.code().unwrap()
|
||||
));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
}
|
||||
@@ -79,7 +88,8 @@ impl MemoryStats {
|
||||
let mut free_cmd = Command::new("free");
|
||||
free_cmd.arg("-k");
|
||||
let stdout = get_cmd_output(free_cmd).await?;
|
||||
let mut numbers = stdout.split_whitespace()
|
||||
let mut numbers = stdout
|
||||
.split_whitespace()
|
||||
.flat_map(|part| part.parse::<usize>());
|
||||
Ok(Self {
|
||||
total: humanize_kb(numbers.next().ok_or("error parsing free output")?),
|
||||
@@ -91,13 +101,15 @@ impl MemoryStats {
|
||||
|
||||
// turns a number of kilobytes (like 28293) into a human-readable string (like "28.3M")
|
||||
fn humanize_kb(kb: usize) -> String {
|
||||
if kb < 1000{
|
||||
if kb < 1000 {
|
||||
return format!("{}K", kb);
|
||||
}
|
||||
format!("{:.1}M", kb as f64 / 1024.0)
|
||||
}
|
||||
|
||||
pub async fn get_system_stats(State(state): State<Arc<ServerState>>) -> Result<Json<SystemStats>, (StatusCode, String)> {
|
||||
pub async fn get_system_stats(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
) -> Result<Json<SystemStats>, (StatusCode, String)> {
|
||||
let qmdl_store = state.qmdl_store_lock.read().await;
|
||||
match SystemStats::new(qmdl_store.path.to_str().unwrap()).await {
|
||||
Ok(stats) => Ok(Json(stats)),
|
||||
@@ -105,9 +117,9 @@ pub async fn get_system_stats(State(state): State<Arc<ServerState>>) -> Result<J
|
||||
error!("error getting system stats: {}", err);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"error getting system stats".to_string()
|
||||
"error getting system stats".to_string(),
|
||||
))
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +129,9 @@ pub struct ManifestStats {
|
||||
pub current_entry: Option<ManifestEntry>,
|
||||
}
|
||||
|
||||
pub async fn get_qmdl_manifest(State(state): State<Arc<ServerState>>) -> Result<Json<ManifestStats>, (StatusCode, String)> {
|
||||
pub async fn get_qmdl_manifest(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
) -> Result<Json<ManifestStats>, (StatusCode, String)> {
|
||||
let qmdl_store = state.qmdl_store_lock.read().await;
|
||||
let mut entries = qmdl_store.manifest.entries.clone();
|
||||
let current_entry = qmdl_store.current_entry.map(|index| entries.remove(index));
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
td,
|
||||
th {
|
||||
border: 1px solid rgb(190, 190, 190);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
td {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
tr:nth-child(even) {
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
th[scope='col'] {
|
||||
background-color: #696969;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
th[scope='row'] {
|
||||
background-color: #d7d9f2;
|
||||
}
|
||||
|
||||
tr.current {
|
||||
background-color: #53fe7b;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
tr.warning {
|
||||
background-color: #fe537b;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
caption {
|
||||
padding: 10px;
|
||||
caption-side: bottom;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
border: 2px solid rgb(200, 200, 200);
|
||||
letter-spacing: 1px;
|
||||
font-family: sans-serif;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>rayhunter</title>
|
||||
<link rel="stylesheet" type="text/css" href="css/style.css">
|
||||
<script src="js/main.js"></script>
|
||||
<script>
|
||||
async function repeatedlyPopulate() {
|
||||
await populateDivs();
|
||||
setTimeout(repeatedlyPopulate, 1000);
|
||||
}
|
||||
window.onload = function() {
|
||||
repeatedlyPopulate();
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div>
|
||||
<button onclick="startRecording()">Start Recording</button>
|
||||
<button onclick="stopRecording()">Stop Recording</button>
|
||||
</div>
|
||||
<table id="qmdl-manifest-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">Date Started</th>
|
||||
<th scope="col">Date of Last Message</th>
|
||||
<th scope="col">Size (bytes)</th>
|
||||
<th scope="col">PCAP</th>
|
||||
<th scope="col">QMDL</th>
|
||||
<th scope="col">Analysis Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
<div>
|
||||
<h3>Live System stats</h3>
|
||||
<pre id="system-stats">Loading...</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Analysis Report of Current Capture</h3>
|
||||
<pre id="analysis-report">Loading...</pre>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,206 +0,0 @@
|
||||
const STATUS_RUNNING = 'running';
|
||||
const STATUS_QUEUED = 'queued';
|
||||
const STATUS_NEEDS_UPDATE = 'needs-update';
|
||||
const STATUS_COMPLETE = 'complete';
|
||||
|
||||
async function populateDivs() {
|
||||
const systemStats = await getSystemStats();
|
||||
const systemStatsDiv = document.getElementById('system-stats');
|
||||
systemStatsDiv.innerHTML = JSON.stringify(systemStats, null, 2);
|
||||
|
||||
const analysisReportDiv = document.getElementById('analysis-report');
|
||||
try {
|
||||
const analysisReport = await getAnalysisReport('live');
|
||||
analysisReportDiv.innerHTML = JSON.stringify(analysisReport, null, 2);
|
||||
} catch (e) {
|
||||
analysisReportDiv.innerHTML = e.toString();
|
||||
}
|
||||
|
||||
const qmdlManifest = await getQmdlManifest();
|
||||
await updateAnalysisStatus(qmdlManifest);
|
||||
await updateAnalysisResults(qmdlManifest);
|
||||
updateQmdlManifestTable(qmdlManifest);
|
||||
}
|
||||
|
||||
function setStatus(qmdlManifest, name, status) {
|
||||
// ignore qmdlManifest.current_entry, it's always running
|
||||
for (const entry of qmdlManifest.entries) {
|
||||
if (entry.name === name) {
|
||||
entry['status'] = status;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAnalysisStatus(qmdlManifest) {
|
||||
const status = JSON.parse(await req('GET', '/api/analysis'));
|
||||
if (status.running) {
|
||||
setStatus(qmdlManifest, status.running, STATUS_RUNNING);
|
||||
}
|
||||
for (const queued in status.queued) {
|
||||
setStatus(qmdlManifest, queued, STATUS_QUEUED);
|
||||
}
|
||||
}
|
||||
|
||||
function parseNewlineDelimitedJSON(inputStr) {
|
||||
const lines = inputStr.split('\n');
|
||||
const result = [];
|
||||
let currentLine = '';
|
||||
while (lines.length > 0) {
|
||||
currentLine += lines.shift();
|
||||
try {
|
||||
const entry = JSON.parse(currentLine);
|
||||
result.push(entry);
|
||||
currentLine = '';
|
||||
// if this chunk wasn't valid JSON, there was an escaped newline in the
|
||||
// JSON line, so simply continue to the next one
|
||||
} catch (e) {}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function updateEntryAnalysisResult(entry) {
|
||||
entry.analysis = {
|
||||
warnings: [],
|
||||
};
|
||||
const report = parseNewlineDelimitedJSON(await req('GET', `/api/analysis-report/${entry.name}`));
|
||||
for (const row of report) {
|
||||
if (row["analysis"]) {
|
||||
const timestamp = new Date(row["timestamp"]);
|
||||
const analysis = row["analysis"];
|
||||
for (const warning of analysis) {
|
||||
entry.analysis.warnings.push({
|
||||
timestamp,
|
||||
warning,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if (entry.analysis.warnings.length === 0) {
|
||||
entry.analysis_result = `0 warnings!`;
|
||||
} else {
|
||||
entry.analysis_result = `!!! ${entry.analysis.warnings.length} warnings !!!`;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAnalysisResults(qmdlManifest) {
|
||||
if (qmdlManifest.current_entry) {
|
||||
await updateEntryAnalysisResult(qmdlManifest.current_entry);
|
||||
}
|
||||
for (const entry of qmdlManifest.entries) {
|
||||
if (entry.status === STATUS_NEEDS_UPDATE) {
|
||||
await updateEntryAnalysisResult(entry);
|
||||
entry.status = STATUS_COMPLETE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateQmdlManifestTable(manifest) {
|
||||
const table = document.getElementById('qmdl-manifest-table');
|
||||
const numRows = table.rows.length;
|
||||
for (let i=1; i<numRows; i++) {
|
||||
table.deleteRow(1);
|
||||
}
|
||||
if (manifest.current_entry) {
|
||||
const row = createEntryRow(manifest.current_entry, true);
|
||||
row.classList.add('current');
|
||||
table.appendChild(row)
|
||||
}
|
||||
for (let entry of manifest.entries) {
|
||||
table.appendChild(createEntryRow(entry), false);
|
||||
}
|
||||
}
|
||||
|
||||
function createLink(uri, text) {
|
||||
const link = document.createElement('a');
|
||||
link.href = uri;
|
||||
link.innerText = text;
|
||||
return link;
|
||||
}
|
||||
|
||||
function createEntryRow(entry, isCurrent) {
|
||||
const row = document.createElement('tr');
|
||||
const name = document.createElement('th');
|
||||
name.scope = 'row';
|
||||
name.innerText = entry.name;
|
||||
row.appendChild(name);
|
||||
|
||||
for (const key of ['start_time', 'last_message_time', 'qmdl_size_bytes']) {
|
||||
const td = document.createElement('td');
|
||||
td.innerText = entry[key];
|
||||
row.appendChild(td);
|
||||
}
|
||||
|
||||
const pcapTd = document.createElement('td');
|
||||
pcapTd.appendChild(createLink(`/api/pcap/${entry.name}`, 'pcap'));
|
||||
row.appendChild(pcapTd);
|
||||
|
||||
const qmdlTd = document.createElement('td');
|
||||
qmdlTd.appendChild(createLink(`/api/qmdl/${entry.name}`, 'qmdl'));
|
||||
row.appendChild(qmdlTd);
|
||||
|
||||
const analysisResult = document.createElement('td');
|
||||
analysisResult.innerText = entry.analysis_result;
|
||||
if (entry.analysis.warnings.length > 0) {
|
||||
row.classList.add("warning");
|
||||
}
|
||||
row.appendChild(analysisResult);
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
async function getAnalysisReport(name) {
|
||||
const rows = await req('GET', `/api/analysis-report/${name}`);
|
||||
return rows.split('\n')
|
||||
.filter(row => row.length > 0)
|
||||
.map(row => JSON.parse(row));
|
||||
}
|
||||
|
||||
async function getSystemStats() {
|
||||
return JSON.parse(await req('GET', '/api/system-stats'));
|
||||
}
|
||||
|
||||
async function getQmdlManifest() {
|
||||
const manifest = JSON.parse(await req('GET', '/api/qmdl-manifest'));
|
||||
if (manifest.current_entry) {
|
||||
manifest.current_entry.status = STATUS_NEEDS_UPDATE;
|
||||
manifest.current_entry.analysis_result = 'Waiting...';
|
||||
manifest.current_entry.start_time = new Date(manifest.current_entry.start_time);
|
||||
if (manifest.current_entry.last_message_time === undefined) {
|
||||
manifest.current_entry.last_message_time = "N/A";
|
||||
} else {
|
||||
manifest.current_entry.last_message_time = new Date(manifest.current_entry.last_message_time);
|
||||
}
|
||||
}
|
||||
for (entry of manifest.entries) {
|
||||
entry.status = STATUS_NEEDS_UPDATE;
|
||||
entry.analysis_result = 'Waiting...';
|
||||
entry.start_time = new Date(entry.start_time);
|
||||
entry.last_message_time = new Date(entry.last_message_time);
|
||||
}
|
||||
// sort them in reverse chronological order
|
||||
manifest.entries.reverse();
|
||||
return manifest;
|
||||
}
|
||||
|
||||
async function startRecording() {
|
||||
await req('POST', '/api/start-recording');
|
||||
populateDivs();
|
||||
}
|
||||
|
||||
async function stopRecording() {
|
||||
await req('POST', '/api/stop-recording');
|
||||
populateDivs();
|
||||
}
|
||||
|
||||
async function req(method, url) {
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
});
|
||||
const body = await response.text();
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
return body;
|
||||
} else {
|
||||
throw new Error(body);
|
||||
}
|
||||
}
|
||||
24
bin/web/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
1
bin/web/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
4
bin/web/.prettierignore
Normal file
@@ -0,0 +1,4 @@
|
||||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
17
bin/web/.prettierrc
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": [
|
||||
"prettier-plugin-svelte"
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
33
bin/web/eslint.config.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import prettier from "eslint-config-prettier";
|
||||
import js from '@eslint/js';
|
||||
import svelte from 'eslint-plugin-svelte';
|
||||
import globals from 'globals';
|
||||
import ts from 'typescript-eslint';
|
||||
|
||||
export default ts.config(
|
||||
js.configs.recommended,
|
||||
...ts.configs.recommended,
|
||||
...svelte.configs["flat/recommended"],
|
||||
prettier,
|
||||
...svelte.configs['flat/prettier'],
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ["**/*.svelte"],
|
||||
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
parser: ts.parser
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: ["build/", ".svelte-kit/", "dist/"]
|
||||
}
|
||||
);
|
||||
37
bin/web/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"test:unit": "vitest",
|
||||
"test": "npm run test:unit -- --run",
|
||||
"format": "prettier --write .",
|
||||
"lint": "prettier --check . && eslint ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.5",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@types/eslint": "^9.6.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.7.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-svelte": "^2.36.0",
|
||||
"globals": "^15.0.0",
|
||||
"prettier": "^3.3.2",
|
||||
"prettier-plugin-svelte": "^3.2.6",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^3.4.9",
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"vite": "^5.0.3",
|
||||
"vitest": "^2.0.4"
|
||||
}
|
||||
}
|
||||
6
bin/web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
3
bin/web/src/app.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@import "tailwindcss/base";
|
||||
@import "tailwindcss/components";
|
||||
@import "tailwindcss/utilities"
|
||||
13
bin/web/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
12
bin/web/src/app.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents" class="m-4 xl:m-8">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
45
bin/web/src/lib/analysis.spec.svelte.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { EventType, parse_finished_report, Severity, type QualitativeWarning } from './analysis.svelte';
|
||||
import { parse_ndjson, type NewlineDeliminatedJson } from './ndjson';
|
||||
|
||||
const SAMPLE_REPORT_NDJSON: NewlineDeliminatedJson = [
|
||||
{ "analyzers": [{ "name": "LTE SIB 6/7 Downgrade", "description": "Tests for LTE cells broadcasting a SIB type 6 and 7 which include 2G/3G frequencies with higher priorities." }, { "name": "IMSI Provided", "description": "Tests whether the UE's IMSI was ever provided to the cell" }, { "name": "Null Cipher", "description": "Tests whether the cell suggests using a null cipher (EEA0)" }, { "name": "Example Analyzer", "description": "Always returns true, if you are seeing this you are either a developer or you are about to have problems." }] },
|
||||
{ "timestamp": "2024-10-08T13:25:43.011689003-07:00", "skipped_message_reasons": ["DecodingError(UperDecodeError(Error { cause: BufferTooShort, msg: \"PerCodec:DecodeError:Requested Bits to decode 3, Remaining bits 1\", context: [] }))"], "analysis": [] },
|
||||
{ "timestamp": "2024-10-08T13:25:43.480872496-07:00", "skipped_message_reasons": [], "analysis": [{ "timestamp": "2024-08-19T03:33:54.318Z", "events": [null, null, null, { "event_type": { "type": "QualitativeWarning", "severity": "Low" }, "message": "TMSI was provided to cell" }] }] },
|
||||
];
|
||||
|
||||
describe('analysis report parsing', () => {
|
||||
it('parses the example analysis', () => {
|
||||
const report = parse_finished_report(SAMPLE_REPORT_NDJSON);
|
||||
expect(report.metadata.analyzers).toEqual([
|
||||
{
|
||||
"name":"LTE SIB 6/7 Downgrade",
|
||||
"description":"Tests for LTE cells broadcasting a SIB type 6 and 7 which include 2G/3G frequencies with higher priorities.",
|
||||
},
|
||||
{
|
||||
"name":"IMSI Provided",
|
||||
"description":"Tests whether the UE's IMSI was ever provided to the cell",
|
||||
},
|
||||
{
|
||||
"name":"Null Cipher",
|
||||
"description":"Tests whether the cell suggests using a null cipher (EEA0)",
|
||||
},
|
||||
{
|
||||
"name":"Example Analyzer",
|
||||
"description":"Always returns true, if you are seeing this you are either a developer or you are about to have problems.",
|
||||
}
|
||||
]);
|
||||
expect(report.rows).toHaveLength(2);
|
||||
expect(report.rows[0].skipped_message_reasons).toHaveLength(1);
|
||||
expect(report.rows[0].analysis).toHaveLength(0);
|
||||
expect(report.rows[1].skipped_message_reasons).toHaveLength(0);
|
||||
expect(report.rows[1].analysis).toHaveLength(1);
|
||||
expect(report.rows[1].analysis[0].events).toHaveLength(1);
|
||||
const event = report.rows[1].analysis[0].events[0];
|
||||
if (event.type === EventType.Warning) {
|
||||
expect(event.severity).toEqual(Severity.Low);
|
||||
} else {
|
||||
throw 'wrong event type';
|
||||
}
|
||||
});
|
||||
});
|
||||
118
bin/web/src/lib/analysis.svelte.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { parse_ndjson, type NewlineDeliminatedJson } from "./ndjson";
|
||||
import { req } from "./utils.svelte";
|
||||
|
||||
export type AnalysisReport = {
|
||||
metadata: ReportMetadata;
|
||||
rows: AnalysisRow[];
|
||||
statistics: ReportStatistics;
|
||||
};
|
||||
|
||||
export type ReportStatistics = {
|
||||
num_warnings: number;
|
||||
num_informational_logs: number;
|
||||
num_skipped_packets: number;
|
||||
}
|
||||
|
||||
export type ReportMetadata = {
|
||||
analyzers: AnalyzerMetadata[];
|
||||
rayhunter: RayhunterMetadata;
|
||||
};
|
||||
|
||||
export type RayhunterMetadata = {
|
||||
rayhunter_version: string;
|
||||
system_os: string;
|
||||
arch: string;
|
||||
};
|
||||
|
||||
export type AnalyzerMetadata = {
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type AnalysisRow = {
|
||||
timestamp: Date;
|
||||
skipped_message_reasons: string[];
|
||||
analysis: PacketAnalysis[];
|
||||
};
|
||||
|
||||
export type PacketAnalysis = {
|
||||
timestamp: Date;
|
||||
events: Event[];
|
||||
};
|
||||
export type Event = QualitativeWarning | InformationalEvent;
|
||||
export enum EventType {
|
||||
Informational,
|
||||
Warning,
|
||||
}
|
||||
|
||||
export type QualitativeWarning = {
|
||||
type: EventType.Warning;
|
||||
severity: Severity;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export enum Severity {
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
}
|
||||
|
||||
export type InformationalEvent = {
|
||||
type: EventType.Informational;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export function parse_finished_report(report_json: NewlineDeliminatedJson): AnalysisReport {
|
||||
const metadata: ReportMetadata = report_json[0]; // this can be cast directly
|
||||
let num_warnings = 0;
|
||||
let num_informational_logs = 0;
|
||||
let num_skipped_packets = 0;
|
||||
const rows: AnalysisRow[] = report_json.slice(1).map((row_json: any) => {
|
||||
const analysis: PacketAnalysis[] = row_json.analysis.map((analysis_json: any) => {
|
||||
const events: Event[] = analysis_json.events.map((event_json: any): Event | null => {
|
||||
if (event_json === null) {
|
||||
return null;
|
||||
} else if (event_json.event_type.type === "Informational") {
|
||||
num_informational_logs += 1;
|
||||
return {
|
||||
type: EventType.Informational,
|
||||
message: event_json.message,
|
||||
};
|
||||
} else {
|
||||
num_warnings += 1;
|
||||
return {
|
||||
type: EventType.Warning,
|
||||
severity: event_json.event_type.severity === "High" ? Severity.High :
|
||||
event_json.event_type.severity === "Medium" ? Severity.Medium : Severity.Low,
|
||||
message: event_json.message,
|
||||
};
|
||||
}
|
||||
})
|
||||
.filter((maybe_event: Event | null) => maybe_event !== null);
|
||||
return {
|
||||
timestamp: analysis_json.timestamp,
|
||||
events,
|
||||
};
|
||||
});
|
||||
num_skipped_packets += row_json.skipped_message_reasons.length;
|
||||
return {
|
||||
timestamp: new Date(row_json.timestamp),
|
||||
skipped_message_reasons: row_json.skipped_message_reasons,
|
||||
analysis,
|
||||
};
|
||||
});
|
||||
return {
|
||||
statistics: {
|
||||
num_informational_logs,
|
||||
num_warnings,
|
||||
num_skipped_packets,
|
||||
},
|
||||
metadata,
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
export async function get_report(name: string): Promise<AnalysisReport> {
|
||||
const report_json = parse_ndjson(await req('GET', `/api/analysis-report/${name}`));
|
||||
return parse_finished_report(report_json);
|
||||
}
|
||||
63
bin/web/src/lib/analysisManager.svelte.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { get_report, type AnalysisReport } from "./analysis.svelte";
|
||||
import type { Manifest, ManifestEntry } from "./manifest.svelte";
|
||||
import { req } from "./utils.svelte";
|
||||
|
||||
export enum AnalysisStatus {
|
||||
// rayhunter is currently analyzing this entry (note that this is distinct
|
||||
// from the currently-recording entry)
|
||||
Running,
|
||||
// this entry is queued to be analyzed
|
||||
Queued,
|
||||
// analysis is finished, and the new report can be accessed
|
||||
Finished,
|
||||
}
|
||||
|
||||
type AnalysisStatusJson = {
|
||||
running: string | null;
|
||||
queued: string[];
|
||||
finished: string[];
|
||||
};
|
||||
|
||||
export type AnalysisResult = {
|
||||
name: string,
|
||||
status: AnalysisStatus,
|
||||
};
|
||||
|
||||
export class AnalysisManager {
|
||||
public status: Map<string, AnalysisStatus> = new Map();
|
||||
public reports: Map<string, AnalysisReport | string> = new Map();
|
||||
|
||||
public async run_analysis(name: string) {
|
||||
await req('POST', `/api/analysis/${name}`);
|
||||
this.status.set(name, AnalysisStatus.Queued);
|
||||
this.reports.delete(name);
|
||||
}
|
||||
|
||||
public async update() {
|
||||
const status: AnalysisStatusJson = JSON.parse(await req('GET', '/api/analysis'));
|
||||
if (status.running) {
|
||||
this.status.set(status.running, AnalysisStatus.Running);
|
||||
}
|
||||
|
||||
for (const entry of status.queued) {
|
||||
this.status.set(entry, AnalysisStatus.Queued);
|
||||
}
|
||||
|
||||
for (const entry of status.finished) {
|
||||
// if entry was already finished, nothing to do
|
||||
if (this.status.get(entry) === AnalysisStatus.Finished) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.status.set(entry, AnalysisStatus.Finished);
|
||||
|
||||
// fetch the analysis report
|
||||
this.reports.delete(entry);
|
||||
get_report(entry).then(report => {
|
||||
this.reports.set(entry, report);
|
||||
}).catch(err => {
|
||||
this.reports.set(entry, `Failed to get analysis: ${err}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
52
bin/web/src/lib/components/AnalysisStatus.svelte
Normal file
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { AnalysisStatus } from "$lib/analysisManager.svelte";
|
||||
import { EventType } from "$lib/analysis.svelte";
|
||||
import type { ManifestEntry } from "$lib/manifest.svelte";
|
||||
let { entry, onclick, analysis_visible}: {
|
||||
entry: ManifestEntry,
|
||||
onclick: () => void,
|
||||
analysis_visible: boolean,
|
||||
} = $props();
|
||||
|
||||
let summary = $derived.by(() => {
|
||||
if (entry.analysis_status === AnalysisStatus.Queued) {
|
||||
return 'Queued...';
|
||||
} else if (entry.analysis_status === AnalysisStatus.Running) {
|
||||
return 'Running...';
|
||||
} else if (entry.analysis_status === AnalysisStatus.Finished) {
|
||||
if (entry.analysis_report === undefined) {
|
||||
return 'Loading...';
|
||||
} else if (typeof(entry.analysis_report) === 'string') {
|
||||
return entry.analysis_report;
|
||||
} else {
|
||||
let num_warnings = 0;
|
||||
for (let row of entry.analysis_report.rows) {
|
||||
for (let analysis of row.analysis) {
|
||||
for (let event of analysis.events) {
|
||||
if (event.type === EventType.Warning) {
|
||||
num_warnings += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return `${num_warnings} warnings`;
|
||||
}
|
||||
} else {
|
||||
return 'Loading...';
|
||||
}
|
||||
});
|
||||
|
||||
let ready = $derived.by(() => {
|
||||
let finished = entry.analysis_status === AnalysisStatus.Finished;
|
||||
let report_available = entry.analysis_report !== undefined;
|
||||
return finished && report_available;
|
||||
})
|
||||
|
||||
let button_class = $derived(ready ? "text-blue-600 border rounded-full px-2" : '');
|
||||
</script>
|
||||
<button class="flex flex-row gap-1 lg:gap-2" disabled={!ready} {onclick}>
|
||||
<span class="{button_class} {entry.get_num_warnings() < 1 ? 'text-green-700 border-green-500 bg-green-200' : 'text-red-700 border-red-500 bg-red-200'}">{summary}</span>
|
||||
<svg class="w-6 h-6 text-gray-800 transition-transform {analysis_visible ? 'rotate-180' : ''}" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 9-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
87
bin/web/src/lib/components/AnalysisTable.svelte
Normal file
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
import { AnalysisStatus } from "$lib/analysisManager.svelte";
|
||||
import { EventType, type AnalyzerMetadata, type ReportMetadata, type AnalysisRow, type AnalysisReport } from "$lib/analysis.svelte";
|
||||
import type { ManifestEntry } from "$lib/manifest.svelte";
|
||||
let { report }: {
|
||||
report: AnalysisReport,
|
||||
} = $props();
|
||||
|
||||
const date_formatter = new Intl.DateTimeFormat(undefined, {
|
||||
timeStyle: "long",
|
||||
dateStyle: "short",
|
||||
});
|
||||
|
||||
const skipped_messages: Map<string, number> = $derived.by(() => {
|
||||
let map = new Map();
|
||||
for (const row of report.rows) {
|
||||
for (const message of row.skipped_message_reasons) {
|
||||
let count = map.get(message);
|
||||
if (count === undefined) {
|
||||
count = 0;
|
||||
}
|
||||
map.set(message, count + 1);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
});
|
||||
</script>
|
||||
<div>
|
||||
<p class="text-lg underline">Warnings and Informational Logs</p>
|
||||
{#if report.statistics.num_warnings === 0 && report.statistics.num_informational_logs === 0}
|
||||
<p>Nothing to show!</p>
|
||||
{:else}
|
||||
<table class="table-auto text-left">
|
||||
<thead class="p-2">
|
||||
<tr class="bg-gray-300">
|
||||
<th class="p-2">Timestamp</th>
|
||||
<th class="p-2">Warning</th>
|
||||
<th class="p-2">Severity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each report.rows as row, row_idx}
|
||||
{#each row.analysis as analysis}
|
||||
{@const parsed_date = new Date(analysis.timestamp)}
|
||||
{#each analysis.events.filter(e => e !== null) as event}
|
||||
<tr class="even:bg-gray-200 odd:bg-white">
|
||||
{#if event.type === EventType.Warning}
|
||||
{@const severity = ['Low', 'Medium', 'High'][event.severity]}
|
||||
{@const severity_class = ['bg-red-200', 'bg-red-400', 'bg-red-600'][event.severity]}
|
||||
<td class="p-2">{date_formatter.format(parsed_date)}</td>
|
||||
<td class="p-2">{event.message}</td>
|
||||
<td class="p-2 {severity_class} text-center">{severity}</td>
|
||||
{:else if event.type === EventType.Informational}
|
||||
<td class="p-2">{date_formatter.format(parsed_date)}</td>
|
||||
<td class="p-2">{event.message}</td>
|
||||
<td class="p-2">Info</td>
|
||||
{/if}
|
||||
</tr>
|
||||
{/each}
|
||||
{/each}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
{#if report.statistics.num_skipped_packets > 0}
|
||||
<div>
|
||||
<p class="text-lg underline">Unparsed Messages</p>
|
||||
<p>These are due to a limitation or bug in Rayhunter's parser, and aren't ususally a problem.</p>
|
||||
<table class="table-auto text-left">
|
||||
<thead class="p-2">
|
||||
<tr class="bg-gray-300">
|
||||
<th scope="col" class="p-2">Total Msgs Affected</th>
|
||||
<th scope="col">Reason/Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each skipped_messages.entries() as [message, count]}
|
||||
<tr class="even:bg-gray-200 odd:bg-white">
|
||||
<td class="text-center">{count}</td>
|
||||
<td>{message}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
46
bin/web/src/lib/components/AnalysisView.svelte
Normal file
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { AnalysisStatus } from "$lib/analysisManager.svelte";
|
||||
import { EventType, type AnalyzerMetadata, type ReportMetadata, type AnalysisRow } from "$lib/analysis.svelte";
|
||||
import type { ManifestEntry } from "$lib/manifest.svelte";
|
||||
import AnalysisTable from "./AnalysisTable.svelte";
|
||||
let { entry }: {
|
||||
entry: ManifestEntry,
|
||||
} = $props();
|
||||
|
||||
const date_formatter = new Intl.DateTimeFormat(undefined, {
|
||||
timeStyle: "long",
|
||||
dateStyle: "short",
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="container mt-2">
|
||||
{#if entry.analysis_report === undefined}
|
||||
<p>Report unavailable, try refreshing.</p>
|
||||
{:else if typeof(entry.analysis_report) === 'string'}
|
||||
<p>Error getting analysis report: {entry.analysis_report}</p>
|
||||
{:else}
|
||||
{@const metadata: ReportMetadata = entry.analysis_report.metadata}
|
||||
<div class="flex flex-col gap-2">
|
||||
{#if entry.analysis_report.rows.length > 0}
|
||||
<AnalysisTable report={entry.analysis_report} />
|
||||
{:else}
|
||||
<p>No warnings to display!</p>
|
||||
{/if}
|
||||
{#if metadata !== undefined && metadata.rayhunter !== undefined}
|
||||
<div>
|
||||
<p class="text-lg underline">Metadata</p>
|
||||
<p>Analysis by Rayhunter version {metadata.rayhunter.rayhunter_version}</p>
|
||||
<p><b>Device system OS:</b> {metadata.rayhunter.system_os}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-lg underline">Analyzers</p>
|
||||
{#each metadata.analyzers as analyzer}
|
||||
<p><b>{analyzer.name}:</b> {analyzer.description}</p>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p>N/A (analysis generated by an older version of rayhunter)</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
18
bin/web/src/lib/components/DeleteAllButton.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { req } from "$lib/utils.svelte";
|
||||
import DeleteButton from "./DeleteButton.svelte";
|
||||
|
||||
function confirmDelete() {
|
||||
if (window.confirm(`Permanently delete ALL recordings?`)) {
|
||||
req('POST', '/api/delete-all-recordings')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-row justify-end gap-2">
|
||||
<DeleteButton
|
||||
text="Delete ALL Recordings"
|
||||
prompt={`Are you sure you want to delete ALL recordings?`}
|
||||
url={`/api/delete-all-recordings`}
|
||||
/>
|
||||
</div>
|
||||
28
bin/web/src/lib/components/DeleteButton.svelte
Normal file
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { ManifestEntry } from "$lib/manifest.svelte";
|
||||
import { req } from "$lib/utils.svelte";
|
||||
let { text, url, prompt }: {
|
||||
text?: string,
|
||||
url: string,
|
||||
prompt: string,
|
||||
} = $props();
|
||||
|
||||
function confirmDelete() {
|
||||
if (window.confirm(prompt)) {
|
||||
req('POST', url)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<button class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded-md flex flex-row" onclick={confirmDelete} aria-label="delete">
|
||||
<p>{text}</p>
|
||||
<svg
|
||||
style="width:24px;height:24px"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="white"
|
||||
d="M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
18
bin/web/src/lib/components/DownloadLink.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
let { url, text, full_button=false }: {
|
||||
url: string;
|
||||
text: string;
|
||||
full_button?: boolean;
|
||||
} = $props();
|
||||
|
||||
function download() {
|
||||
window.location.href = url;
|
||||
}
|
||||
</script>
|
||||
|
||||
<button class="flex flex-row {full_button ? 'bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-md' : 'text-blue-600 underline'}" onclick={download}>
|
||||
{text}
|
||||
<svg class="fill-current w-4 h-4 m-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
<path d="M13 8V2H7v6H2l8 8 8-8h-5zM0 18h20v2H0v-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
74
bin/web/src/lib/components/ManifestCard.svelte
Normal file
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import { ManifestEntry } from "$lib/manifest.svelte";
|
||||
import DownloadLink from '$lib/components/DownloadLink.svelte';
|
||||
import DeleteButton from "$lib/components/DeleteButton.svelte";
|
||||
import AnalysisStatus from "./AnalysisStatus.svelte";
|
||||
import AnalysisView from "./AnalysisView.svelte";
|
||||
import RecordingControls from "./RecordingControls.svelte";
|
||||
let { entry, current, i, server_is_recording }: {
|
||||
entry: ManifestEntry;
|
||||
current: boolean;
|
||||
i: number;
|
||||
server_is_recording: boolean;
|
||||
} = $props();
|
||||
|
||||
// passing `undefined` as the locale uses the browser default
|
||||
const date_formatter = new Intl.DateTimeFormat(undefined, {
|
||||
timeStyle: "long",
|
||||
dateStyle: "short",
|
||||
});
|
||||
let status_row_color = $derived.by(() => {
|
||||
const num_warnings = entry.get_num_warnings();
|
||||
if (num_warnings !== undefined && num_warnings > 0) {
|
||||
return "bg-red-100";
|
||||
}
|
||||
return current ? "bg-green-100" : "bg-gray-100"
|
||||
});
|
||||
let status_border_color = $derived.by(() => {
|
||||
const num_warnings = entry.get_num_warnings();
|
||||
if (num_warnings !== undefined && num_warnings > 0) {
|
||||
return "border-red-100";
|
||||
}
|
||||
return current ? "border-green-100" : "border-gray-100"
|
||||
});
|
||||
let analysis_visible = $state(false);
|
||||
function toggle_analysis_visibility() {
|
||||
analysis_visible = !analysis_visible;
|
||||
}
|
||||
</script>
|
||||
<div class="{status_row_color} {status_border_color} drop-shadow p-4 flex flex-col gap-2 border rounded-md flex-1">
|
||||
{#if current}
|
||||
<div class="flex flex-row justify-between gap-2">
|
||||
<span class="text-xl mb-2">Current Recording</span>
|
||||
<span class=""><AnalysisStatus onclick={toggle_analysis_visibility} entry={entry} analysis_visible={analysis_visible}/></span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-row justify-between">
|
||||
<span class="font-bold">ID: {entry.name}</span>
|
||||
{#if !current}
|
||||
<span class=""><AnalysisStatus onclick={toggle_analysis_visibility} entry={entry} analysis_visible={analysis_visible}/></span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="">{entry.get_readable_qmdl_size()}</span>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="">Start: {date_formatter.format(entry.start_time)}</span>
|
||||
<span class="">Last Message: {date_formatter.format(entry.last_message_time)}</span>
|
||||
</div>
|
||||
<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_qmdl_url()} text="qmdl" full_button=true />
|
||||
{#if current}
|
||||
<RecordingControls {server_is_recording} />
|
||||
{:else}
|
||||
<DeleteButton
|
||||
prompt={`Are you sure you want to delete entry ${entry.name}?`}
|
||||
url={entry.get_delete_url()}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="border-b {analysis_visible ? '' : 'hidden'}">
|
||||
<AnalysisView {entry} />
|
||||
</div>
|
||||
</div>
|
||||
37
bin/web/src/lib/components/ManifestTable.svelte
Normal file
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { Manifest, ManifestEntry } from "$lib/manifest.svelte";
|
||||
import TableRow from "./ManifestTableRow.svelte";
|
||||
import Card from "./ManifestCard.svelte"
|
||||
interface Props {
|
||||
entries: ManifestEntry[];
|
||||
server_is_recording: boolean;
|
||||
}
|
||||
let { entries, server_is_recording }: Props = $props();
|
||||
</script>
|
||||
|
||||
<!--For larger screens we use a table-->
|
||||
<table class="hidden table-auto text-left lg:table">
|
||||
<thead>
|
||||
<tr class="bg-gray-100 drop-shadow">
|
||||
<th class='p-2' scope="col">ID</th>
|
||||
<th class='p-2' scope="col">Started</th>
|
||||
<th class='p-2' scope="col">Last Message</th>
|
||||
<th class='p-2' scope="col">Size</th>
|
||||
<th class='p-2' scope="col">PCAP</th>
|
||||
<th class='p-2' scope="col">QMDL</th>
|
||||
<th class='p-2' scope="col">Analysis</th>
|
||||
<th class='p-2' scope="col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each entries as entry, i}
|
||||
<TableRow {entry} current={false} {i} />
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<!--For smaller screens we use cards-->
|
||||
<div class="lg:hidden flex flex-col gap-4">
|
||||
{#each entries as entry, i}
|
||||
<Card {entry} current={false} {i} />
|
||||
{/each}
|
||||
</div>
|
||||
55
bin/web/src/lib/components/ManifestTableRow.svelte
Normal file
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { ManifestEntry } from "$lib/manifest.svelte";
|
||||
import DownloadLink from '$lib/components/DownloadLink.svelte';
|
||||
import DeleteButton from "$lib/components/DeleteButton.svelte";
|
||||
import AnalysisStatus from "./AnalysisStatus.svelte";
|
||||
import AnalysisView from "./AnalysisView.svelte";
|
||||
let { entry, current, i }: {
|
||||
entry: ManifestEntry;
|
||||
current: boolean;
|
||||
i: number;
|
||||
} = $props();
|
||||
|
||||
// passing `undefined` as the locale uses the browser default
|
||||
const date_formatter = new Intl.DateTimeFormat(undefined, {
|
||||
timeStyle: "long",
|
||||
dateStyle: "short",
|
||||
});
|
||||
let alternating_row_color = $derived(i % 2 == 0 ? "bg-white" : "bg-gray-100");
|
||||
let status_row_color = $derived.by(() => {
|
||||
const num_warnings = entry.get_num_warnings();
|
||||
if (num_warnings !== undefined && num_warnings > 0) {
|
||||
return "bg-red-100";
|
||||
}
|
||||
return current ? "bg-green-100" : alternating_row_color
|
||||
});
|
||||
let analysis_visible = $state(false);
|
||||
function toggle_analysis_visibility() {
|
||||
analysis_visible = !analysis_visible;
|
||||
}
|
||||
</script>
|
||||
|
||||
<tr class="{status_row_color} drop-shadow">
|
||||
<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.last_message_time)}</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_qmdl_url()} text="qmdl" /></td>
|
||||
<td class="p-2"><AnalysisStatus onclick={toggle_analysis_visibility} entry={entry} analysis_visible={analysis_visible}/></td>
|
||||
{#if current}
|
||||
<td class="p-2"></td>
|
||||
{:else}
|
||||
<td class="p-2">
|
||||
<DeleteButton
|
||||
prompt={`Are you sure you want to delete entry ${entry.name}?`}
|
||||
url={entry.get_delete_url()}
|
||||
/>
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
<tr class="{alternating_row_color} border-b {analysis_visible ? '' : 'hidden'}">
|
||||
<td class="border-t border-dashed p-2" colspan="8">
|
||||
<AnalysisView {entry} />
|
||||
</td>
|
||||
</tr>
|
||||
46
bin/web/src/lib/components/RecordingControls.svelte
Normal file
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { req } from "$lib/utils.svelte";
|
||||
let { server_is_recording }: {
|
||||
server_is_recording: boolean;
|
||||
} = $props();
|
||||
|
||||
let client_set_recording = $state(server_is_recording);
|
||||
let waiting_for_server = $derived(client_set_recording !== server_is_recording);
|
||||
|
||||
async function start_recording() {
|
||||
await req('POST', '/api/start-recording');
|
||||
client_set_recording = true;
|
||||
}
|
||||
|
||||
async function stop_recording() {
|
||||
await req('POST', '/api/stop-recording');
|
||||
client_set_recording = false;
|
||||
}
|
||||
|
||||
const recording_button_classes = "text-white font-bold py-2 px-4 rounded-md flex flex-row gap-1";
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{#if waiting_for_server}
|
||||
<button class={server_is_recording ? stop_recording_classes : start_recording_classes}>
|
||||
{server_is_recording ? "Stopping..." : "Starting..."}
|
||||
</button>
|
||||
{:else if server_is_recording}
|
||||
<button class="{recording_button_classes} bg-red-500 hover:bg-red-700" onclick={stop_recording}>
|
||||
<span>Stop</span>
|
||||
<svg class="w-6 h-6 text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M7 5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H7Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
{:else}
|
||||
<button class="{recording_button_classes} bg-blue-500 hover:bg-blue-700" onclick={start_recording}>
|
||||
<span>Start</span>
|
||||
<svg class="w-6 h-6 text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path fill-rule="evenodd" d="M8.6 5.2A1 1 0 0 0 7 6v12a1 1 0 0 0 1.6.8l8-6a1 1 0 0 0 0-1.6l-8-6Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
38
bin/web/src/lib/components/SystemStatsTable.svelte
Normal file
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import { type SystemStats } from "$lib/systemStats";
|
||||
let { stats }: {
|
||||
stats: SystemStats;
|
||||
} = $props();
|
||||
|
||||
const table_cell_classes = "border p-1 lg:p-2";
|
||||
</script>
|
||||
|
||||
<div class="flex-1 drop-shadow p-4 flex flex-col gap-2 border rounded-md bg-gray-100 border-gray-100">
|
||||
<p class="text-xl mb-2">System Information</p>
|
||||
<table class="table-auto border">
|
||||
<tbody>
|
||||
<tr class="border">
|
||||
<th class={table_cell_classes}>
|
||||
Rayhunter Version
|
||||
</th>
|
||||
<td class={table_cell_classes}>{stats.runtime_metadata.rayhunter_version}</td>
|
||||
</tr>
|
||||
<tr class="border">
|
||||
<th class={table_cell_classes}>
|
||||
Storage
|
||||
</th>
|
||||
<td class={table_cell_classes}>
|
||||
{stats.disk_stats.used_percent} used ({stats.disk_stats.used_size} used / {stats.disk_stats.available_size} available)
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b">
|
||||
<th class={table_cell_classes}>
|
||||
Memory (RAM)
|
||||
</th>
|
||||
<td class={table_cell_classes}>
|
||||
Free: {stats.memory_stats.free}, Used: {stats.memory_stats.used}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
1
bin/web/src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
103
bin/web/src/lib/manifest.svelte.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { get_report, type AnalysisReport } from "./analysis.svelte";
|
||||
import { AnalysisStatus, type AnalysisManager } from "./analysisManager.svelte";
|
||||
|
||||
interface JsonManifest {
|
||||
entries: JsonManifestEntry[];
|
||||
current_entry: JsonManifestEntry | null;
|
||||
}
|
||||
|
||||
interface JsonManifestEntry {
|
||||
name: string;
|
||||
start_time: string;
|
||||
last_message_time: string;
|
||||
qmdl_size_bytes: number;
|
||||
analysis_size_bytes: number;
|
||||
}
|
||||
|
||||
export class Manifest {
|
||||
public entries: ManifestEntry[] = [];
|
||||
public current_entry: ManifestEntry | undefined;
|
||||
|
||||
constructor(json: JsonManifest) {
|
||||
for (let entry of json.entries) {
|
||||
this.entries.push(new ManifestEntry(entry));
|
||||
}
|
||||
if (json.current_entry !== null) {
|
||||
this.current_entry = new ManifestEntry(json['current_entry']);
|
||||
}
|
||||
|
||||
// sort entries in reverse chronological order
|
||||
this.entries.reverse();
|
||||
}
|
||||
|
||||
async set_analysis_status(manager: AnalysisManager) {
|
||||
for (let entry of this.entries) {
|
||||
entry.analysis_status = manager.status.get(entry.name);
|
||||
entry.analysis_report = manager.reports.get(entry.name);
|
||||
}
|
||||
|
||||
if (this.current_entry) {
|
||||
try {
|
||||
this.current_entry.analysis_report = await get_report(this.current_entry.name);
|
||||
} catch(err) {
|
||||
this.current_entry.analysis_report = `Err: failed to get analysis report: ${err}`;
|
||||
}
|
||||
|
||||
// the current entry should always be considered "finished", as its
|
||||
// analysis report is always available
|
||||
this.current_entry.analysis_status = AnalysisStatus.Finished;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ManifestEntry {
|
||||
public name = $state("");
|
||||
public start_time: Date;
|
||||
public last_message_time: Date | undefined = $state(undefined);
|
||||
public qmdl_size_bytes = $state(0);
|
||||
public analysis_size_bytes = $state(0);
|
||||
public analysis_status: AnalysisStatus | undefined = $state(undefined);
|
||||
public analysis_report: AnalysisReport | string | undefined = $state(undefined);
|
||||
|
||||
constructor(json: JsonManifestEntry) {
|
||||
this.name = json.name;
|
||||
this.qmdl_size_bytes = json.qmdl_size_bytes;
|
||||
this.analysis_size_bytes = json.analysis_size_bytes;
|
||||
this.start_time = new Date(json.start_time);
|
||||
if (json.last_message_time !== undefined) {
|
||||
this.last_message_time = new Date(json.last_message_time);
|
||||
}
|
||||
}
|
||||
|
||||
get_readable_qmdl_size(): string {
|
||||
if (this.qmdl_size_bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const dm = 2 || 2;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||
const i = Math.floor(Math.log(this.qmdl_size_bytes) / Math.log(k));
|
||||
return `${Number.parseFloat((this.qmdl_size_bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
get_num_warnings(): number | undefined {
|
||||
if (this.analysis_report === undefined || typeof(this.analysis_report) === 'string') {
|
||||
return undefined;
|
||||
}
|
||||
return this.analysis_report.statistics.num_warnings;
|
||||
}
|
||||
|
||||
get_pcap_url(): string {
|
||||
return `/api/pcap/${this.name}`;
|
||||
}
|
||||
|
||||
get_qmdl_url(): string {
|
||||
return `/api/qmdl/${this.name}`;
|
||||
}
|
||||
|
||||
get_analysis_report_url(): string {
|
||||
return `/api/analysis-report/${this.name}`;
|
||||
}
|
||||
|
||||
get_delete_url(): string {
|
||||
return `/api/delete-recording/${this.name}`;
|
||||
}
|
||||
}
|
||||
33
bin/web/src/lib/ndjson.spec.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parse_ndjson } from './ndjson';
|
||||
|
||||
describe('parsing newline-deliminated json', () => {
|
||||
it('parses normal JSON', () => {
|
||||
const json = JSON.stringify({ foo: 100 });
|
||||
const result = parse_ndjson(json);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({ foo: 100 });
|
||||
});
|
||||
|
||||
it('parses simple newline-deliminated json', () => {
|
||||
const json_a = JSON.stringify({ a: 100 });
|
||||
const json_b = JSON.stringify({ b: 200 });
|
||||
const result = parse_ndjson(`${json_a}\n${json_b}`);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual({ a: 100 });
|
||||
expect(result[1]).toEqual({ b: 200 });
|
||||
})
|
||||
|
||||
it('parses newline-deliminated json with escaped newlines within', () => {
|
||||
const json_a = JSON.stringify({ a: 'this one has\n newlines and\nstuff' });
|
||||
const json_b = JSON.stringify({ b: 200 });
|
||||
const result = parse_ndjson(`${json_a}\n${json_b}`);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual({ a: 'this one has\n newlines and\nstuff' });
|
||||
expect(result[1]).toEqual({ b: 200 });
|
||||
})
|
||||
|
||||
it('actually errors out on invalid ndjson', () => {
|
||||
expect(() => parse_ndjson("invalid\njson")).toThrow();
|
||||
});
|
||||
});
|
||||
27
bin/web/src/lib/ndjson.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export type NewlineDeliminatedJson = any[];
|
||||
|
||||
export function parse_ndjson(input: string): NewlineDeliminatedJson {
|
||||
const lines = input.split('\n');
|
||||
const result = [];
|
||||
let current_line = '';
|
||||
while (lines.length > 0) {
|
||||
current_line += lines.shift();
|
||||
if (current_line.length === 0) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const entry = JSON.parse(current_line);
|
||||
result.push(entry);
|
||||
current_line = '';
|
||||
} catch (e) {
|
||||
// if this chunk wasn't valid JSON, assume there was an escaped
|
||||
// newline in the JSON line, so simply continue to the next one.
|
||||
// however, if we've reached the end of the input, that means we
|
||||
// were given invalid nd-json
|
||||
if (lines.length === 0) {
|
||||
throw new Error(`unable to parse invalid nd-json: ${e}, "${current_line}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
26
bin/web/src/lib/systemStats.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export interface SystemStats {
|
||||
disk_stats: DiskStats;
|
||||
memory_stats: MemoryStats;
|
||||
runtime_metadata: RuntimeMetadata;
|
||||
}
|
||||
|
||||
export interface RuntimeMetadata {
|
||||
rayhunter_version: string,
|
||||
system_os: string,
|
||||
arch: string,
|
||||
}
|
||||
|
||||
export interface DiskStats {
|
||||
partition: string,
|
||||
total_size: string,
|
||||
used_size: string,
|
||||
available_size: string,
|
||||
used_percent: string,
|
||||
mounted_on: string,
|
||||
}
|
||||
|
||||
export interface MemoryStats {
|
||||
total: string,
|
||||
used: string,
|
||||
free: string,
|
||||
}
|
||||
23
bin/web/src/lib/utils.svelte.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Manifest } from "./manifest.svelte";
|
||||
import type { SystemStats } from "./systemStats";
|
||||
|
||||
export async function req(method: string, url: string): Promise<string> {
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
});
|
||||
const body = await response.text();
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
return body;
|
||||
} else {
|
||||
throw new Error(body);
|
||||
}
|
||||
}
|
||||
|
||||
export async function get_manifest(): Promise<Manifest> {
|
||||
const manifest_json = JSON.parse(await req('GET', '/api/qmdl-manifest'));
|
||||
return new Manifest(manifest_json);
|
||||
}
|
||||
|
||||
export async function get_system_stats(): Promise<SystemStats> {
|
||||
return JSON.parse(await req('GET', '/api/system-stats'));
|
||||
}
|
||||
1
bin/web/src/routes/+layout.js
Normal file
@@ -0,0 +1 @@
|
||||
export const prerender = true;
|
||||
6
bin/web/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,6 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
84
bin/web/src/routes/+page.svelte
Normal file
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import { ManifestEntry } from "$lib/manifest.svelte";
|
||||
import { get_manifest, get_system_stats } from "$lib/utils.svelte";
|
||||
import ManifestTable from "$lib/components/ManifestTable.svelte";
|
||||
import Card from "$lib/components/ManifestCard.svelte";
|
||||
import type { SystemStats } from "$lib/systemStats";
|
||||
import { AnalysisManager } from "$lib/analysisManager.svelte";
|
||||
import SystemStatsTable from "$lib/components/SystemStatsTable.svelte";
|
||||
import DeleteAllButton from "$lib/components/DeleteAllButton.svelte";
|
||||
import RecordingControls from "$lib/components//RecordingControls.svelte";
|
||||
|
||||
let manager: AnalysisManager = new AnalysisManager();
|
||||
let loaded = $state(false);
|
||||
let recording = $state(false);
|
||||
let entries: ManifestEntry[] = $state([]);
|
||||
let current_entry: ManifestEntry | undefined = $state(undefined);
|
||||
let system_stats: SystemStats | undefined = $state(undefined);
|
||||
$effect(() => {
|
||||
const interval = setInterval(async () => {
|
||||
await manager.update();
|
||||
let new_manifest = await get_manifest();
|
||||
await new_manifest.set_analysis_status(manager);
|
||||
entries = new_manifest.entries;
|
||||
current_entry = new_manifest.current_entry;
|
||||
recording = current_entry !== undefined;
|
||||
|
||||
system_stats = await get_system_stats();
|
||||
loaded = true;
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="p-4 xl:px-8 bg-rayhunter-blue drop-shadow flex flex-row justify-between items-center">
|
||||
<img src="/rayhunter_text.png" class="h-10 xl:h-12"/>
|
||||
<div class="flex flex-row gap-4">
|
||||
<a class="flex flex-row gap-1 group" href="https://github.com/EFForg/rayhunter/issues" target="_blank">
|
||||
<span class="hidden text-white group-hover:text-gray-400 lg:flex">Report Issue</span>
|
||||
<svg class="w-6 h-6 text-white group-hover:text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path fill-rule="evenodd" d="M12.006 2a9.847 9.847 0 0 0-6.484 2.44 10.32 10.32 0 0 0-3.393 6.17 10.48 10.48 0 0 0 1.317 6.955 10.045 10.045 0 0 0 5.4 4.418c.504.095.683-.223.683-.494 0-.245-.01-1.052-.014-1.908-2.78.62-3.366-1.21-3.366-1.21a2.711 2.711 0 0 0-1.11-1.5c-.907-.637.07-.621.07-.621.317.044.62.163.885.346.266.183.487.426.647.71.135.253.318.476.538.655a2.079 2.079 0 0 0 2.37.196c.045-.52.27-1.006.635-1.37-2.219-.259-4.554-1.138-4.554-5.07a4.022 4.022 0 0 1 1.031-2.75 3.77 3.77 0 0 1 .096-2.713s.839-.275 2.749 1.05a9.26 9.26 0 0 1 5.004 0c1.906-1.325 2.74-1.05 2.74-1.05.37.858.406 1.828.101 2.713a4.017 4.017 0 0 1 1.029 2.75c0 3.939-2.339 4.805-4.564 5.058a2.471 2.471 0 0 1 .679 1.897c0 1.372-.012 2.477-.012 2.814 0 .272.18.592.687.492a10.05 10.05 0 0 0 5.388-4.421 10.473 10.473 0 0 0 1.313-6.948 10.32 10.32 0 0 0-3.39-6.165A9.847 9.847 0 0 0 12.007 2Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a class="flex flex-row gap-1 group" href="https://efforg.github.io/rayhunter/" target="_blank">
|
||||
<span class="hidden text-white group-hover:text-gray-400 lg:flex">Docs</span>
|
||||
<svg class="w-6 h-6 text-white group-hover:text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 19V4a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v13H7a2 2 0 0 0-2 2Zm0 0a2 2 0 0 0 2 2h12M9 3v14m7 0v4"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="m-4 xl:mx-8 flex flex-col gap-4">
|
||||
{#if loaded}
|
||||
<div class="flex flex-col lg:flex-row gap-4">
|
||||
{#if recording}
|
||||
<Card entry={current_entry} current={true} i={0} server_is_recording={recording}/>
|
||||
{:else}
|
||||
<div class="bg-red-100 border-red-100 drop-shadow p-4 flex flex-col gap-2 border rounded-md flex-1 justify-between">
|
||||
<span class="text-2xl font-bold mb-2 flex flex-row items-center gap-2 text-red-600">
|
||||
<svg class="w-8 h-8 text-red-600" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path fill-rule="evenodd" d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm11-4a1 1 0 1 0-2 0v5a1 1 0 1 0 2 0V8Zm-1 7a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H12Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
WARNING: Not Running
|
||||
</span>
|
||||
<span>Rayhunter is not currently running and will not detect abnormal behavior!</span>
|
||||
<div class="flex flex-row justify-end mt-2">
|
||||
<RecordingControls {recording} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<SystemStatsTable stats={system_stats!} />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-xl">History</span>
|
||||
<ManifestTable entries={entries} server_is_recording={recording} />
|
||||
</div>
|
||||
<DeleteAllButton/>
|
||||
{:else}
|
||||
<div class="flex flex-col justify-center items-center">
|
||||
<img src="/rayhunter_orca_only.png" class="h-48 animate-spin"/>
|
||||
<p class="text-xl">Loading...</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
BIN
bin/web/static/favicon.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
4
bin/web/static/pico.min.css
vendored
Normal file
BIN
bin/web/static/rayhunter_icon.png
Normal file
|
After Width: | Height: | Size: 218 KiB |
BIN
bin/web/static/rayhunter_orca_only.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
bin/web/static/rayhunter_text.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
15
bin/web/svelte.config.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
|
||||
export default {
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
// default options are shown. On some platforms
|
||||
// these options are set automatically — see below
|
||||
pages: 'build',
|
||||
assets: 'build',
|
||||
fallback: undefined,
|
||||
precompress: false,
|
||||
strict: true
|
||||
})
|
||||
}
|
||||
};
|
||||
17
bin/web/tailwind.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
export default {
|
||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'rayhunter-blue': '#4e4eb1',
|
||||
'rayhunter-dark-blue': '#3f3da0',
|
||||
'rayhunter-green': '#94ea18'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
plugins: []
|
||||
} as Config;
|
||||
19
bin/web/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||
}
|
||||
30
bin/web/vite.config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
configure: (proxy, _options) => {
|
||||
proxy.on('error', (err, _req, _res) => {
|
||||
console.log('proxy err:', err);
|
||||
});
|
||||
proxy.on('proxyReq', (proxyReq, req, _res) => {
|
||||
console.log('Sending Request to the Target:', req.method, req.url);
|
||||
});
|
||||
proxy.on('proxyRes', (proxyRes, req, _res) => {
|
||||
console.log('Received Response from the Target:', proxyRes.statusCode, req.url);
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [sveltekit()],
|
||||
|
||||
test: {
|
||||
include: ['src/**/*.{test,spec}.{js,ts}']
|
||||
}
|
||||
});
|
||||
5
book.toml
Normal file
@@ -0,0 +1,5 @@
|
||||
[book]
|
||||
authors = ["The Rayhunter Team"]
|
||||
language = "en"
|
||||
src = "doc"
|
||||
title = "Rayhunter - An IMSI Catcher Catcher"
|
||||
15
dist/config.toml.example
vendored
@@ -1,9 +1,22 @@
|
||||
# cat config.toml
|
||||
qmdl_store_path = "/data/rayhunter/qmdl"
|
||||
port = 8080
|
||||
debug_mode = false
|
||||
enable_dummy_analyzer = false
|
||||
colorblind_mode = false
|
||||
# UI Levels:
|
||||
#
|
||||
# Orbic and TP-Link with color display:
|
||||
# 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
|
||||
# 1 = Subtle mode, display a colored line at the top of the screen when rayhunter is running (green=running, white=paused, red=warnings)
|
||||
# 2 = Demo Mode, display a fun orca gif
|
||||
# 3 = display the EFF logo
|
||||
#
|
||||
# TP-Link with one-bit display:
|
||||
# 0 = invisible mode
|
||||
# 1..3 = show emoji for status. :) for running, ! for warnings, no mouth for paused.
|
||||
ui_level = 1
|
||||
|
||||
# 0 = rayhunter does not read button presses
|
||||
# 1 = double-tapping the power button starts/stops recordings
|
||||
key_input_mode = 1
|
||||
|
||||
106
dist/install-common.sh
vendored
@@ -1,106 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
install() {
|
||||
if [[ -z "${SERIAL_PATH}" ]]; then
|
||||
echo "\$SERIAL_PATH not set, did you run this from install-linux.sh or install-mac.sh?"
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "${ADB}" ]]; then
|
||||
echo "\$ADB not set, did you run this from install-linux.sh or install-mac.sh?"
|
||||
exit 1
|
||||
fi
|
||||
force_debug_mode
|
||||
setup_rootshell
|
||||
setup_rayhunter
|
||||
test_rayhunter
|
||||
}
|
||||
|
||||
force_debug_mode() {
|
||||
echo "Using adb at $ADB"
|
||||
echo "Force a switch into the debug mode to enable ADB"
|
||||
"$SERIAL_PATH" --root
|
||||
echo -n "adb enabled, waiting for reboot..."
|
||||
wait_for_adb_shell
|
||||
echo " it's alive!"
|
||||
echo -n "waiting for atfwd_daemon to startup..."
|
||||
wait_for_atfwd_daemon
|
||||
echo " done!"
|
||||
}
|
||||
|
||||
wait_for_atfwd_daemon() {
|
||||
until [ -n "$(_adb_shell 'pgrep atfwd_daemon')" ]
|
||||
do
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
wait_for_adb_shell() {
|
||||
until _adb_shell true 2> /dev/null
|
||||
do
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
setup_rootshell() {
|
||||
_adb_push rootshell /tmp/
|
||||
_at_syscmd "cp /tmp/rootshell /bin/rootshell"
|
||||
sleep 1
|
||||
_at_syscmd "chown root /bin/rootshell"
|
||||
sleep 1
|
||||
_at_syscmd "chmod 4755 /bin/rootshell"
|
||||
_adb_shell '/bin/rootshell -c id'
|
||||
echo "we have root!"
|
||||
}
|
||||
|
||||
_adb_push() {
|
||||
"$ADB" push "$(dirname "$0")/$1" "$2"
|
||||
}
|
||||
|
||||
_adb_shell() {
|
||||
"$ADB" shell "$1"
|
||||
}
|
||||
|
||||
_at_syscmd() {
|
||||
"$SERIAL_PATH" "AT+SYSCMD=$1"
|
||||
}
|
||||
|
||||
setup_rayhunter() {
|
||||
_at_syscmd "mkdir -p /data/rayhunter"
|
||||
_adb_push config.toml.example /data/rayhunter/config.toml
|
||||
_adb_push rayhunter-daemon /data/rayhunter/
|
||||
_adb_push scripts/rayhunter_daemon /tmp/rayhunter_daemon
|
||||
_adb_push scripts/misc-daemon /tmp/misc-daemon
|
||||
_at_syscmd "cp /tmp/rayhunter_daemon /etc/init.d/rayhunter_daemon"
|
||||
_at_syscmd "cp /tmp/misc-daemon /etc/init.d/misc-daemon"
|
||||
_at_syscmd "chmod 755 /etc/init.d/rayhunter_daemon"
|
||||
_at_syscmd "chmod 755 /etc/init.d/misc-daemon"
|
||||
echo -n "waiting for reboot..."
|
||||
_at_syscmd reboot
|
||||
|
||||
# first wait for shutdown (it can take ~10s)
|
||||
until ! _adb_shell true 2> /dev/null
|
||||
do
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# now wait for boot to finish
|
||||
wait_for_adb_shell
|
||||
|
||||
echo " done!"
|
||||
}
|
||||
|
||||
test_rayhunter() {
|
||||
URL="http://localhost:8080"
|
||||
"$ADB" forward tcp:8080 tcp:8080 > /dev/null
|
||||
echo -n "checking for rayhunter server..."
|
||||
|
||||
SECONDS=0
|
||||
while (( SECONDS < 30 )); do
|
||||
if curl -L --fail-with-body "$URL" -o /dev/null -s; then
|
||||
echo "success!"
|
||||
echo "you can access rayhunter at $URL"
|
||||
return
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "timeout reached! failed to reach rayhunter url $URL, something went wrong :("
|
||||
}
|
||||
17
dist/install-linux.sh
vendored
@@ -1,17 +0,0 @@
|
||||
#!/bin/env bash
|
||||
|
||||
set -e
|
||||
if ! command -v adb &> /dev/null; then
|
||||
if [ ! -d ./platform-tools ] ; then
|
||||
echo "adb not found, downloading local copy"
|
||||
curl -O "https://dl.google.com/android/repository/platform-tools-latest-linux.zip"
|
||||
unzip platform-tools-latest-linux.zip
|
||||
fi
|
||||
export ADB="./platform-tools/adb"
|
||||
else
|
||||
export ADB=`which adb`
|
||||
fi
|
||||
|
||||
export SERIAL_PATH="./serial-ubuntu-latest/serial"
|
||||
. "$(dirname "$0")"/install-common.sh
|
||||
install
|
||||
17
dist/install-mac.sh
vendored
@@ -1,17 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
if ! command -v adb &> /dev/null; then
|
||||
if [ ! -d ./platform-tools ]; then
|
||||
echo "adb not found, downloading local copy"
|
||||
curl -O "https://dl.google.com/android/repository/platform-tools-latest-darwin.zip"
|
||||
unzip platform-tools-latest-darwin.zip
|
||||
fi
|
||||
export ADB="./platform-tools/adb"
|
||||
else
|
||||
export ADB=`which adb`
|
||||
fi
|
||||
|
||||
export SERIAL_PATH="./serial-macos-latest/serial"
|
||||
. "$(dirname "$0")"/install-common.sh
|
||||
install
|
||||
1
dist/install-windows.bat
vendored
@@ -1 +0,0 @@
|
||||
ECHO TODO
|
||||
6
dist/scripts/rayhunter_daemon
vendored
@@ -1,12 +1,14 @@
|
||||
#! /bin/bash
|
||||
#! /bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
echo -n "Starting rayhunter: "
|
||||
# Below line may be replaced by the installer with device-specific startup commands, such as mounting the SD card.
|
||||
#RAYHUNTER-PRESTART
|
||||
start-stop-daemon -S -b --make-pidfile --pidfile /tmp/rayhunter.pid \
|
||||
--startas /bin/bash -- -c "RUST_LOG=info exec /data/rayhunter/rayhunter-daemon /data/rayhunter/config.toml > /data/rayhunter/rayhunter.log 2>&1"
|
||||
--startas /bin/sh -- -c "RUST_LOG=info exec /data/rayhunter/rayhunter-daemon /data/rayhunter/config.toml > /data/rayhunter/rayhunter.log 2>&1"
|
||||
echo "done"
|
||||
;;
|
||||
stop)
|
||||
|
||||
BIN
doc/Rayhunter_0.3.2.png
Normal file
|
After Width: | Height: | Size: 152 KiB |
17
doc/SUMMARY.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Summary
|
||||
|
||||
[Introduction](./introduction.md)
|
||||
- [Installation](./installation.md)
|
||||
- [Installing from the latest release](./installing-from-release.md)
|
||||
- [Installing from the latest release (Windows)](./installing-from-release-windows.md)
|
||||
- [Installing from source](./installing-from-source.md)
|
||||
- [Updating Rayhunter](./updating-rayhunter.md)
|
||||
- [Uninstalling](./uninstalling.md)
|
||||
- [Using Rayhunter](./using-rayhunter.md)
|
||||
- [Rayhunter's heuristics](./heuristics.md)
|
||||
- [How we analyze a capture](./analyzing-a-capture.md)
|
||||
- [Supported devices](./supported-devices.md)
|
||||
- [TP-Link M7350](./tplink-m7350.md)
|
||||
- [Orbic RC400L](./orbic.md)
|
||||
- [Support, feedback, and community](./support-feedback-community.md)
|
||||
- [Frequently Asked Questions](./faq.md)
|
||||
3
doc/analyzing-a-capture.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# How we analyze a capture
|
||||
|
||||
TODO
|
||||
20
doc/faq.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Frequently Asked Questions
|
||||
|
||||
### Do I need an active SIM card to use Rayhunter?
|
||||
|
||||
**It Depends**. Operation of Rayhunter does require the insertion of a SIM card into the device, but whether that SIM card has to be currently active for our tests to work is still under investigation. If you want to use the device as a hotspot in addition to a research device an active plan would of course be necessary, however we have not done enough testing yet to know whether an active subscription is required for detection. If you want to test the device with an inactive SIM card, we would certainly be interested in seeing any data you collect, and especially any runs that trigger an alert!
|
||||
|
||||
<a name="red"></a>
|
||||
|
||||
### Help, Rayhunter's line is red! What should I do?
|
||||
|
||||
Unfortunately, the circumstances that might lead to a positive cell site simulator (CSS) signal are quite varied, so we don't have a universal recommendation for how to deal with the a positive signal. Depending on your circumstances and threat model, you may want to turn off your phone until you are out of the area (or put it on airplane mode) and tell your friends to do the same!
|
||||
|
||||
If you've received a Rayhunter warning and would like to help us with our research, please send your Rayhunter data captures (QMDL and PCAP logs) to us at our [Signal](https://signal.org/) username [**ElectronicFrontierFoundation.90**](https://signal.me/#eu/HZbPPED5LyMkbTxJsG2PtWc2TXxPUR1OxBMcJGLOPeeCDGPuaTpOi5cfGRY6RrGf) with the following information: capture date, capture location, device, device model, and Rayhunter version. If you're unfamiliar with Signal, feel free to check out our [Security Self Defense guide on it](https://ssd.eff.org/module/how-to-use-signal).
|
||||
|
||||
Please note that this file may contain sensitive information such as your IMSI and the unique IDs of cell towers you were near which could be used to ascertain your location at the time.
|
||||
|
||||
|
||||
### Should I get a locked or unlocked orbic device? What is the difference?
|
||||
|
||||
If you want to use a non-Verizon SIM card you will probably need an unlocked device. But it's not clear how locked the locked devices are nor how to unlock them, we welcome any experimentation and information regarding the use of unlocked devices.
|
||||
3
doc/heuristics.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Heuristics
|
||||
|
||||
TODO
|
||||
7
doc/installation.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Installing Rayhunter
|
||||
|
||||
So, you've got one of the [supported devices](./supported-devices.md), and are ready to start catching IMSI catchers. You have two options for installing Rayhunter:
|
||||
|
||||
* [installing from a release (recommended)](./installing-from-release.md)
|
||||
* [installing from a release on Windows](./installing-from-release-windows.md)
|
||||
* [installing from source](./installing-from-source.md)
|
||||
32
doc/installing-from-release-windows.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Installing from the latest release (Windows)
|
||||
|
||||
Windows support in Rayhunter's installer is a work-in-progress. Depending on the device, the installation instructions differ.
|
||||
|
||||
## TP-Link
|
||||
|
||||
1. Connect the device via WiFi or USB Tethering -- you should be able to view the TP-Link admin page on [http://192.168.0.1](http://192.168.0.1).
|
||||
2. Download the latest release (must be at least 0.3.0), and unpack the zipfile.
|
||||
3. Open PowerShell or CMD in that extracted folder, and run the binary appropriate for your operating system: `./installer-windows-x86_64/installer tplink`
|
||||
4. Follow the instructions on the screen, if there are any.
|
||||
|
||||
## Orbic
|
||||
|
||||
1. Connect the device to your computer using the provided USB cable.
|
||||
1. Install the [Zadig WinUSB driver installer](https://zadig.akeo.ie/).
|
||||
1. Open Zadig, click options->show all devices
|
||||
|
||||

|
||||
|
||||
1. Select 'RNDIS (Interface 0)'
|
||||
|
||||

|
||||
|
||||
1. Click 'install driver' and wait for it to finish.
|
||||
2. Download the latest `rayhunter-vX.X.X.zip` from the [Rayhunter releases page](https://github.com/EFForg/rayhunter/releases). The version you download will have numbers instead of X
|
||||
3. Unzip `rayhunter-vX.X.X` .
|
||||
1. Open a powershell terminal by pressing Win+R and typing `powershell` and hitting enter.
|
||||
5. Type `cd ~\Downloads\rayhunter-v<x.x.x>\installer-windows-x86_64` (**Replace <x.x.x> with the rayhunter version you just unzipped**) and hit enter.
|
||||
5. Run the install script: `.\installer.exe orbic` and hit enter.
|
||||
- The device will restart multiple times over the next few minutes.
|
||||
- You will know it is done when you see terminal output that says `checking for rayhunter server...success!`
|
||||
6. Rayhunter should now be running! You can verify this by following the instructions below to [view the web UI](#usage-viewing-the-web-ui). You should also see a green line flash along the top of top the display on the device.
|
||||
48
doc/installing-from-release.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Installing from the latest release
|
||||
|
||||
Make sure you've got one of Rayhunter's [supported devices](./supported-devices.md). These instructions have only been tested on macOS and Ubuntu 24.04. If they fail, you will need to [install Rayhunter from source](./installing-from-source.md).
|
||||
|
||||
1. Download the latest `rayhunter-vX.X.X.zip` from the [Rayhunter releases page](https://github.com/EFForg/rayhunter/releases)
|
||||
2. Decompress the `rayhunter-vX.X.X.zip` archive. Open the terminal and navigate to the folder. (Be sure to replace X.X.X with the correct version number!)
|
||||
|
||||
```bash
|
||||
unzip ~/Downloads/rayhunter-vX.X.X.zip
|
||||
cd ~/Downloads/rayhunter-vX.X.X
|
||||
```
|
||||
|
||||
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 TP-Link, connect to its network using either WiFi or USB Tethering.
|
||||
|
||||
4. Run the install script for your operating system:
|
||||
|
||||
First, enter the correct subfolder for your operating system:
|
||||
- for Ubuntu on x64 arhitecture: `cd installer-ubuntu-24`
|
||||
- for Ubuntu on ARM64 arhitecture: `cd installer-ubuntu-24-aarch64`
|
||||
- for MacOS on Intel (old macbooks) architecture: `cd installer-macos-intel`
|
||||
- for MacOS on ARM (M1/M2 etc.) achitecture: `cd installer-macos-arm`
|
||||
- for Windows: `cd installer-windows-x86_64`
|
||||
|
||||
```bash
|
||||
# On MacOS, you must first remove the quarantine bit
|
||||
xattr -d com.apple.quarantine install
|
||||
```
|
||||
Then run the installer:
|
||||
```bash
|
||||
./installer orbic
|
||||
# or: ./installer tplink
|
||||
```
|
||||
|
||||
The device will restart multiple times over the next few minutes.
|
||||
|
||||
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.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
* On MacOS if you encounter an error that says "No Orbic device found," it may because you have the "Allow accessories to connect" security setting set to "Ask for approval." You may need to temporarily change it to "Always" for the script to run. Make sure to change it back to a more secure setting when you're done.
|
||||
|
||||
./installer --help
|
||||
./installer util --help
|
||||
51
doc/installing-from-source.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Installing from source
|
||||
|
||||
Building Rayhunter from source, either for development or because the install script doesn't work on your system, involves a number of external dependencies. Unless you need to do this, we recommend you use our [compiled builds](https://github.com/EFForg/rayhunter/releases).
|
||||
|
||||
* Install [nodejs/npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm), which is required to build Rayhunter's web UI
|
||||
* Make sure to build the site with `cd bin/web && npm install && npm run build` before building Rayhunter. If you're working directly on the frontend, `npm run dev` will allow you to test a local frontend with hot-reloading (use `http://localhost:5173` instead of `http://localhost:8080`).
|
||||
* Install ADB on your computer using the instructions above, and make sure it's in your terminal's PATH
|
||||
* You can verify if ADB is in your PATH by running `which adb` in a terminal. If it prints the filepath to where ADB is installed, you're set! Otherwise, try following one of these guides:
|
||||
* [linux](https://askubuntu.com/questions/652936/adding-android-sdk-platform-tools-to-path-downloaded-from-umake)
|
||||
* [macOS](https://www.repeato.app/setting-up-adb-on-macos-a-step-by-step-guide/)
|
||||
* [Windows](https://medium.com/@yadav-ajay/a-step-by-step-guide-to-setting-up-adb-path-on-windows-0b833faebf18)
|
||||
* Install `curl` on your computer to run the install scripts. It is not needed to build binaries.
|
||||
|
||||
### Install Rust targets
|
||||
|
||||
[Install Rust the usual way](https://www.rust-lang.org/tools/install). Then,
|
||||
|
||||
- install the cross-compilation target for the device rayhunter will run on:
|
||||
```sh
|
||||
rustup target add armv7-unknown-linux-musleabihf
|
||||
```
|
||||
|
||||
- install the statically compiled target for your host machine to build the binary installer `serial`.
|
||||
```sh
|
||||
# check which toolchain you have installed by default with
|
||||
rustup show
|
||||
# now install the correct variant for your host platform, one of:
|
||||
rustup target add x86_64-unknown-linux-musl
|
||||
rustup target add aarch64-unknown-linux-musl
|
||||
rustup target add aarch64-apple-darwin
|
||||
rustup target add x86_64-apple-darwin
|
||||
rustup target add x86_64-pc-windows-gnu
|
||||
```
|
||||
|
||||
Now you can root your device and install Rayhunter by running:
|
||||
|
||||
```sh
|
||||
cargo build --bin rayhunter-daemon --target armv7-unknown-linux-musleabihf --profile firmware --no-default-features --features orbic
|
||||
|
||||
cargo build --bin rootshell --target armv7-unknown-linux-musleabihf --profile firmware
|
||||
|
||||
cargo run --bin installer orbic
|
||||
```
|
||||
|
||||
### If you're on Windows or can't run the install scripts
|
||||
|
||||
* Root your device on Windows using the instructions here: <https://xdaforums.com/t/resetting-verizon-orbic-speed-rc400l-firmware-flash-kajeet.4334899/#post-87855183>
|
||||
* Build the web UI using `cd bin/web && npm install && npm run build`
|
||||
* Push the scripts in `scripts/` to `/etc/init.d` on device and make a directory called `/data/rayhunter` using `adb shell` (and sshell for your root shell if you followed the steps above)
|
||||
* You also need to copy `config.toml.example` to `/data/rayhunter/config.toml`
|
||||
* Then run `./make.sh`, which will build the binary, push it over adb, and restart the device. Once it's restarted, Rayhunter should be running!
|
||||
13
doc/introduction.md
Normal file
@@ -0,0 +1,13 @@
|
||||

|
||||
|
||||
# Rayhunter
|
||||
|
||||
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.
|
||||
|
||||
To learn more about the aim of the project, and about IMSI catchers in general, please check out our [introductory blog post](https://www.eff.org/deeplinks/2025/03/meet-rayhunter-new-open-source-tool-eff-detect-cellular-spying). Otherwise, check out the [installation guide](./installation.md) to get started.
|
||||
|
||||
**LEGAL DISCLAIMER:** Use this program at your own risk. We believe running this program does not currently violate any laws or regulations in the United States. However, we are not responsible for civil or criminal liability resulting from the use of this software. If you are located outside of the US please consult with an attorney in your country to help you assess the legal risks of running this program.
|
||||
|
||||
*Good Hunting!*
|
||||
20
doc/orbic.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Orbic RC400L
|
||||
|
||||
The Orbic RC400L is an inexpensive LTE modem primarily designed for the US marked, and the original device for which Rayhunter is developed.
|
||||
|
||||
You can buy an Orbic [using bezos
|
||||
bucks](https://www.amazon.com/Orbic-Verizon-Hotspot-Connect-Enabled/dp/B08N3CHC4Y),
|
||||
or on [eBay](https://www.ebay.com/sch/i.html?_nkw=orbic+rc400l).
|
||||
|
||||
[Please check whether the Orbic works in your country](https://www.frequencycheck.com/countries/), and whether the Orbic RC400L supports the right frequency bands for your purpose before buying.
|
||||
|
||||
## Supported Bands
|
||||
|
||||
| Frequency | Band |
|
||||
| ------- | ------------------ |
|
||||
| 5G (wideband,midband,nationwide) | n260/n261, n77, n2/5/48/66 |
|
||||
| 4G | 2/4/5/12/13/48/66 |
|
||||
| Global & Roaming | n257/n78 |
|
||||
| Wifi 2.4Ghz | b/g/n |
|
||||
| Wifi 5Ghz | a/ac/ax |
|
||||
| Wifi 6 | 🮱 |
|
||||