Compare commits
427 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
deeab1f1b0 | ||
|
|
83dba77cba | ||
|
|
542aff4fdf | ||
|
|
2ececf9c58 | ||
|
|
2cba26a4cc | ||
|
|
48c0592b18 | ||
|
|
a21c9af354 | ||
|
|
0c241aba23 | ||
|
|
b2502847a1 | ||
|
|
be6f29dcf1 | ||
|
|
2114206909 | ||
|
|
f735f033d3 | ||
|
|
b825174a07 | ||
|
|
29823d3e82 | ||
|
|
e52d382514 | ||
|
|
a17e255148 | ||
|
|
0f98b05475 | ||
|
|
5e5514a11f | ||
|
|
9904b74d21 | ||
|
|
d166dfc13d | ||
|
|
9b759e6b42 | ||
|
|
5614c725a0 | ||
|
|
5a7fc2a063 | ||
|
|
e601320b3f | ||
|
|
0b05d1617c | ||
|
|
e7ba02173a | ||
|
|
0b0dd4ed43 | ||
|
|
f2ff1be2ec | ||
|
|
9f9adea5a1 | ||
|
|
cb2092d14f | ||
|
|
76cdb3ecf1 | ||
|
|
bee5152381 | ||
|
|
2634271715 | ||
|
|
58913314aa | ||
|
|
4f5bf4aa78 | ||
|
|
bfc85c5103 | ||
|
|
7923327ba9 | ||
|
|
3ff714972c | ||
|
|
a5d8e601d9 | ||
|
|
5272a99fb5 | ||
|
|
5d61ad53b4 | ||
|
|
88ee4fc87e | ||
|
|
8aadfc20f2 | ||
|
|
a234df1e1e | ||
|
|
de25008742 | ||
|
|
185da9cb36 | ||
|
|
c366eb9e4d | ||
|
|
5dfbeaef64 | ||
|
|
62e4c15eb5 | ||
|
|
02c98a8e8e | ||
|
|
6c02f56250 | ||
|
|
f56acdf89d | ||
|
|
cb6f79f67a | ||
|
|
95951c5c38 | ||
|
|
241fb2789b | ||
|
|
3a3adb055b | ||
|
|
3ae2636d9e | ||
|
|
79b2628d2f | ||
|
|
cba898daf6 | ||
|
|
cb1df974e4 | ||
|
|
86e08f9a85 | ||
|
|
fb2149f0c8 | ||
|
|
bf2b00ce47 | ||
|
|
fd453900c2 | ||
|
|
3d29c5f306 | ||
|
|
74623dea02 | ||
|
|
48e73a0a41 | ||
|
|
a36863e002 | ||
|
|
48aac0f0bb | ||
|
|
5749c305c6 | ||
|
|
f53688086d | ||
|
|
bd2e0b4394 | ||
|
|
1eea086199 | ||
|
|
d36c1f10cd | ||
|
|
8d8d2bd8ec | ||
|
|
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 | ||
|
|
a23df84848 | ||
|
|
4e862841b3 | ||
|
|
2cc8404b13 | ||
|
|
35ae2962f2 | ||
|
|
1134361cca | ||
|
|
bec680f93d | ||
|
|
968af93b69 | ||
|
|
ee75326912 | ||
|
|
3b9a001e88 | ||
|
|
78d33b2cff | ||
|
|
6c237e884c | ||
|
|
f3e4091e1d | ||
|
|
16f705f29c | ||
|
|
a6fce6d568 | ||
|
|
fcac6fdf16 | ||
|
|
df84faa1f9 | ||
|
|
c59fb7c013 | ||
|
|
ca4f49b15f | ||
|
|
861aaedd47 | ||
|
|
f6681a3703 | ||
|
|
d6bc307a81 | ||
|
|
7cbb3369d8 | ||
|
|
cb3dbff54a | ||
|
|
65e1cd4967 | ||
|
|
d6fb54afb3 | ||
|
|
bc93c01890 | ||
|
|
be2d70325d | ||
|
|
5c4bd161fa |
@@ -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 @@
|
||||
9fe75ac961c57e508bf7488ce51d596750fa8d37
|
||||
19
.github/ISSUE_TEMPLATE/bug.yaml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
name: Bug Report
|
||||
description: File a bug report.
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Bug Report Details
|
||||
description: |
|
||||
Please provide the following information, if applicable:
|
||||
placeholder: |
|
||||
• **Rayhunter Version**: (e.g., v0.2.6)
|
||||
• **Capture Date**: (YYYY-MM-DD, e.g., 2025-05-01)
|
||||
• **Capture Location**: (If comfortable disclosing, what region or country were you in? e.g., Washington State)
|
||||
• **Device and Model**: (Device you installed Rayhunter on, e.g., Orbic RC400L)
|
||||
• **What happened?**: (What steps did you take to get to your issue? Tell us what you see!)
|
||||
• **Expected behavior**: (Rayhunter's behavior differed from what I expected because...)
|
||||
• **Relevant log output**: (Rayhunter data captures - QMDL and PCAP logs - or error codes)
|
||||
validations:
|
||||
required: true
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: true
|
||||
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.
|
||||
26
.github/ISSUE_TEMPLATE/feature.yaml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or improvement to Rayhunter
|
||||
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
|
||||
77
.github/workflows/build-release.yml
vendored
@@ -1,77 +0,0 @@
|
||||
name: Build Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, "release-*"]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
build_serial:
|
||||
strategy:
|
||||
matrix:
|
||||
platform:
|
||||
- os: ubuntu-latest
|
||||
build_name: serial
|
||||
- os: windows-latest
|
||||
build_name: serial.exe
|
||||
- os: macos-latest
|
||||
build_name: serial
|
||||
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.build_name }}
|
||||
if-no-files-found: error
|
||||
build_rootshell_and_rayhunter:
|
||||
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
|
||||
- name: Build rootshell (arm32)
|
||||
run: cargo build --bin rootshell --target armv7-unknown-linux-gnueabihf --release
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rootshell
|
||||
path: target/armv7-unknown-linux-gnueabihf/release/rootshell
|
||||
if-no-files-found: error
|
||||
- name: Build rayhunter-daemon (arm32)
|
||||
run: cargo build --bin rayhunter-daemon --target armv7-unknown-linux-gnueabihf --release
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rayhunter-daemon
|
||||
path: target/armv7-unknown-linux-gnueabihf/release/rayhunter-daemon
|
||||
if-no-files-found: error
|
||||
build_release_zip:
|
||||
needs:
|
||||
- build_serial
|
||||
- build_rootshell_and_rayhunter
|
||||
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-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 .
|
||||
# TODO: have this create a release directly
|
||||
- name: Upload release
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release.tar
|
||||
path: release.tar
|
||||
if-no-files-found: error
|
||||
20
.github/workflows/check-and-test.yml
vendored
@@ -1,20 +0,0 @@
|
||||
name: Check and Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
check_and_test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Check
|
||||
run: cargo check --verbose
|
||||
- name: Run tests
|
||||
run: cargo test --verbose
|
||||
332
.github/workflows/main.yml
vendored
Normal file
@@ -0,0 +1,332 @@
|
||||
name: main
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
workflow_call: # required to call this workflow from another workflow like release.yml
|
||||
|
||||
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
|
||||
FILE_RAYHUNTER_DAEMON_WINGTECH: ../../rayhunter-daemon-wingtech/rayhunter-daemon
|
||||
|
||||
jobs:
|
||||
files_changed:
|
||||
name: Detect file changes
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
code_changed: ${{ steps.files_changed.outputs.code_count }}
|
||||
daemon_changed: ${{ steps.files_changed.outputs.daemon_count }}
|
||||
docs_changed: ${{ steps.files_changed.outputs.docs_count }}
|
||||
installer_changed: ${{ steps.files_changed.outputs.installer_count }}
|
||||
rootshell_changed: ${{ steps.files_changed.outputs.rootshell_count }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: detect file changes
|
||||
id: files_changed
|
||||
run: |
|
||||
lcommit=${{ github.event.pull_request.base.sha || 'origin/main' }}
|
||||
|
||||
# If we are on main, or if these workflow files are being changed, run everything
|
||||
if [ ${{ github.ref }} = 'refs/heads/main' ] || git diff --name-only $lcommit..HEAD | grep -qe ^.github/workflows/
|
||||
then
|
||||
echo "building everything"
|
||||
echo code_count=forced >> "$GITHUB_OUTPUT"
|
||||
echo daemon_count=forced >> "$GITHUB_OUTPUT"
|
||||
echo docs_count=forced >> "$GITHUB_OUTPUT"
|
||||
echo installer_count=forced >> "$GITHUB_OUTPUT"
|
||||
echo rootshell_count=forced >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "code_count=$(git diff --name-only $lcommit...HEAD | grep -e ^bin -e ^installer -e ^lib -e ^rootshell -e ^telcom-parser | wc -l)" >> "$GITHUB_OUTPUT"
|
||||
echo "daemon_count=$(git diff --name-only $lcommit...HEAD | grep -e ^bin -e ^lib -e ^telcom-parser | wc -l)" >> "$GITHUB_OUTPUT"
|
||||
echo "docs_count=$(git diff --name-only $lcommit...HEAD | grep -e ^book.toml -e ^doc | wc -l)" >> "$GITHUB_OUTPUT"
|
||||
echo "installer_count=$(git diff --name-only $lcommit...HEAD | grep -e ^installer | wc -l)" >> "$GITHUB_OUTPUT"
|
||||
echo "rootshell_count=$(git diff --name-only $lcommit...HEAD | grep -e ^rootshell | wc -l)" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
mdbook_test:
|
||||
name: Test mdBook Documentation builds
|
||||
needs: files_changed
|
||||
if: needs.files_changed.outputs.docs_changed != '0'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install mdBook
|
||||
run: |
|
||||
cargo install mdbook --no-default-features --features search --vers "^0.4" --locked
|
||||
- name: Test mdBook
|
||||
run: mdbook test
|
||||
|
||||
mdbook_publish:
|
||||
name: Publish mdBook to Github Pages
|
||||
needs: mdbook_test
|
||||
if: ${{ github.ref == 'refs/heads/main' }}
|
||||
permissions:
|
||||
pages: write
|
||||
contents: write
|
||||
id-token: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install mdBook
|
||||
run: |
|
||||
cargo install mdbook --no-default-features --features search --vers "^0.4" --locked
|
||||
|
||||
- name: Build mdBook
|
||||
run: mdbook build
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v4
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: book
|
||||
- name: Deploy to Github Pages
|
||||
uses: actions/deploy-pages@v4
|
||||
|
||||
check_and_test:
|
||||
needs: files_changed
|
||||
if: needs.files_changed.outputs.code_changed != '0'
|
||||
strategy:
|
||||
matrix:
|
||||
device:
|
||||
- name: orbic
|
||||
- name: tplink
|
||||
- name: wingtech
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Check formatting
|
||||
run: cargo fmt --all --check
|
||||
- name: Check
|
||||
run: |
|
||||
pushd bin/web
|
||||
npm install
|
||||
npm run build
|
||||
popd
|
||||
NO_FIRMWARE_BIN=true cargo check --verbose --no-default-features --features=${{ matrix.device.name }}
|
||||
- name: Run tests
|
||||
run: |
|
||||
NO_FIRMWARE_BIN=true cargo test --verbose --no-default-features --features=${{ matrix.device.name }}
|
||||
- name: Run clippy
|
||||
run: |
|
||||
NO_FIRMWARE_BIN=true cargo clippy --verbose --no-default-features --features=${{ matrix.device.name }}
|
||||
|
||||
windows_installer_check_and_test:
|
||||
needs: files_changed
|
||||
if: needs.files_changed.outputs.installer_changed != '0'
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: cargo check
|
||||
shell: bash
|
||||
run: |
|
||||
cd installer
|
||||
NO_FIRMWARE_BIN=true cargo check --verbose
|
||||
- name: cargo test
|
||||
shell: bash
|
||||
run: |
|
||||
cd installer
|
||||
NO_FIRMWARE_BIN=true cargo test --verbose --no-default-features
|
||||
|
||||
build_rayhunter_check:
|
||||
if: needs.files_changed.outputs.daemon_changed != '0'
|
||||
needs:
|
||||
- check_and_test
|
||||
- files_changed
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
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: Swatinem/rust-cache@v2
|
||||
- name: Build rayhunter-check
|
||||
run: cargo build --bin rayhunter-check --release
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rayhunter-check-${{ matrix.platform.name }}
|
||||
path: target/release/rayhunter-check${{ matrix.platform.os == 'windows-latest' && '.exe' || '' }}
|
||||
if-no-files-found: error
|
||||
|
||||
build_rootshell:
|
||||
if: needs.files_changed.outputs.rootshell_changed != '0'
|
||||
needs:
|
||||
- check_and_test
|
||||
- files_changed
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: armv7-unknown-linux-musleabihf
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Build rootshell (arm32)
|
||||
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-musleabihf/firmware/rootshell
|
||||
if-no-files-found: error
|
||||
|
||||
build_rayhunter:
|
||||
if: needs.files_changed.outputs.daemon_changed != '0'
|
||||
needs:
|
||||
- check_and_test
|
||||
- files_changed
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
matrix:
|
||||
device:
|
||||
- name: orbic
|
||||
- name: tplink
|
||||
- name: wingtech
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: armv7-unknown-linux-musleabihf
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Build rayhunter-daemon (arm32)
|
||||
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-${{ matrix.device.name }}
|
||||
path: target/armv7-unknown-linux-musleabihf/firmware/rayhunter-daemon
|
||||
if-no-files-found: error
|
||||
|
||||
build_rust_installer:
|
||||
if: needs.files_changed.outputs.installer_changed != '0'
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
needs:
|
||||
- build_rayhunter
|
||||
- build_rootshell
|
||||
- files_changed
|
||||
- windows_installer_check_and_test
|
||||
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 }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- 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:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
needs:
|
||||
- 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 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 zip release and sha256
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rayhunter-v${{ env.VERSION }}
|
||||
path: |
|
||||
rayhunter-v${{ env.VERSION }}.zip
|
||||
rayhunter-v${{ env.VERSION }}.zip.sha256
|
||||
if-no-files-found: error
|
||||
48
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
# To use: navigate on Github to Actions, select "Release rayhunter" on the left, click "Run workflow" > "Run workflow" on the right.
|
||||
# https://github.com/EFForg/rayhunter/actions/workflows/release.yml
|
||||
name: Release rayhunter
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
jobs:
|
||||
check_version_same:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Ensure all Cargo.toml files have the same version defined.
|
||||
run: |
|
||||
defined_versions=$(find lib bin installer rootshell telcom-parser -name Cargo.toml -exec grep ^version {} \; | sort -u | wc -l)
|
||||
find lib bin installer rootshell telcom-parser -name Cargo.toml -exec grep ^version {} \;
|
||||
echo number of defined versions = $defined_versions
|
||||
if [ $defined_versions != "1" ]
|
||||
then
|
||||
echo "all Cargo.toml files must have the same version defined"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
main:
|
||||
needs: check_version_same
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
packages: write
|
||||
pages: write
|
||||
uses: ./.github/workflows/main.yml
|
||||
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: main
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
- name: Create release
|
||||
run: |
|
||||
version=$(grep ^version lib/Cargo.toml | cut -d' ' -f3 | tr -d '"')
|
||||
gh release create --generate-notes -t "Rayhunter v$version" "v$version" rayhunter-v${version}/rayhunter-*
|
||||
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).
|
||||
2464
Cargo.lock
generated
@@ -3,8 +3,8 @@
|
||||
members = [
|
||||
"lib",
|
||||
"bin",
|
||||
"serial",
|
||||
"rootshell",
|
||||
"telcom-parser",
|
||||
"installer",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
93
README.md
@@ -1,92 +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
|
||||
|
||||
1. Install the Android Debug Bridge (ADB) on your computer (don't worry about instructions for installing it on a phone/device yet). You can find instructions for doing so on your platform [here](https://www.xda-developers.com/install-adb-windows-macos-linux/#how-to-set-up-adb-on-your-computer).
|
||||
2. Download the latest [rayhunter release bundle](https://github.com/EFForg/rayhunter/releases) and unzip it.
|
||||
3. Run the install script inside the bundle corresponding to your platform (`install-linux.sh`, `install-mac.sh`).
|
||||
4. 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`.
|
||||
|
||||
## Development
|
||||
* Install ADB on your computer using the instructions above.
|
||||
|
||||
### 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 `./install.sh` - **Note:** You will have to install the cross compile tooling below before running this.
|
||||
|
||||
### If you aren't on linux 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,16 @@
|
||||
[package]
|
||||
name = "rayhunter-daemon"
|
||||
version = "0.1.0"
|
||||
version = "0.4.0"
|
||||
edition = "2021"
|
||||
|
||||
[features]
|
||||
# These feature flags are mutually exclusive, and exactly one must be enabled.
|
||||
orbic = ["rayhunter/orbic"]
|
||||
tplink = ["rayhunter/tplink"]
|
||||
wingtech = ["rayhunter/wingtech"]
|
||||
|
||||
default = ["orbic"]
|
||||
|
||||
[[bin]]
|
||||
name = "rayhunter-daemon"
|
||||
path = "src/daemon.rs"
|
||||
@@ -15,20 +23,23 @@ 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", "compat"] }
|
||||
futures-macro = "0.3.30"
|
||||
include_dir = "0.7.3"
|
||||
mime_guess = "2.0.4"
|
||||
tempdir = "0.3.7"
|
||||
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"
|
||||
async_zip = { version = "0.0.17", features = ["tokio"] }
|
||||
anyhow = "1.0.98"
|
||||
|
||||
|
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 |
288
bin/src/analysis.rs
Normal file
@@ -0,0 +1,288 @@
|
||||
use std::sync::Arc;
|
||||
use std::{future, pin};
|
||||
|
||||
use axum::Json;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use futures::TryStreamExt;
|
||||
use log::{debug, error, info};
|
||||
use rayhunter::analysis::analyzer::{AnalyzerConfig, Harness};
|
||||
use rayhunter::diag::{DataType, MessagesContainer};
|
||||
use rayhunter::qmdl::QmdlReader;
|
||||
use serde::Serialize;
|
||||
use tokio::fs::File;
|
||||
use tokio::io::{AsyncWriteExt, BufWriter};
|
||||
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;
|
||||
|
||||
pub struct AnalysisWriter {
|
||||
writer: BufWriter<File>,
|
||||
harness: Harness,
|
||||
bytes_written: usize,
|
||||
}
|
||||
|
||||
// We write our analysis results to a file immediately to minimize the amount of
|
||||
// state Rayhunter has to keep track of in memory. The analysis file's format is
|
||||
// Newline Delimited JSON
|
||||
// (https://docs.mulesoft.com/dataweave/latest/dataweave-formats-ndjson), which
|
||||
// lets us simply append new rows to the end without parsing the entire JSON
|
||||
// object beforehand.
|
||||
impl AnalysisWriter {
|
||||
pub async fn new(
|
||||
file: File,
|
||||
enable_dummy_analyzer: bool,
|
||||
analyzer_config: &AnalyzerConfig,
|
||||
) -> Result<Self, std::io::Error> {
|
||||
let mut harness = Harness::new_with_config(analyzer_config);
|
||||
if enable_dummy_analyzer {
|
||||
harness.add_analyzer(Box::new(TestAnalyzer { count: 0 }));
|
||||
}
|
||||
|
||||
let mut result = Self {
|
||||
writer: BufWriter::new(file),
|
||||
bytes_written: 0,
|
||||
harness,
|
||||
};
|
||||
let metadata = result.harness.get_metadata();
|
||||
result.write(&metadata).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// 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> {
|
||||
let row = self.harness.analyze_qmdl_messages(container);
|
||||
if !row.is_empty() {
|
||||
self.write(&row).await?;
|
||||
}
|
||||
Ok((self.bytes_written, row.contains_warnings()))
|
||||
}
|
||||
|
||||
async fn write<T: Serialize>(&mut self, value: &T) -> Result<(), std::io::Error> {
|
||||
let mut value_str = serde_json::to_string(value).unwrap();
|
||||
value_str.push('\n');
|
||||
self.bytes_written += value_str.len();
|
||||
self.writer.write_all(value_str.as_bytes()).await?;
|
||||
self.writer.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Flushes any pending I/O to disk before dropping the writer
|
||||
pub async fn close(mut self) -> Result<(), std::io::Error> {
|
||||
self.writer.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
async fn queued_len(analysis_status_lock: Arc<RwLock<AnalysisStatus>>) -> usize {
|
||||
analysis_status_lock.read().await.queued.len()
|
||||
}
|
||||
|
||||
async fn dequeue_to_running(analysis_status_lock: Arc<RwLock<AnalysisStatus>>) -> String {
|
||||
let mut analysis_status = analysis_status_lock.write().await;
|
||||
let name = analysis_status.queued.remove(0);
|
||||
assert!(analysis_status.running.is_none());
|
||||
analysis_status.running = Some(name.clone());
|
||||
name
|
||||
}
|
||||
|
||||
async fn finish_running_analysis(analysis_status_lock: Arc<RwLock<AnalysisStatus>>) {
|
||||
let mut analysis_status = analysis_status_lock.write().await;
|
||||
let finished = analysis_status.running.take().unwrap();
|
||||
analysis_status.finished.push(finished);
|
||||
}
|
||||
|
||||
async fn perform_analysis(
|
||||
name: &str,
|
||||
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
|
||||
enable_dummy_analyzer: bool,
|
||||
analyzer_config: &AnalyzerConfig,
|
||||
) -> Result<(), String> {
|
||||
info!("Opening QMDL and analysis file for {}...", name);
|
||||
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)
|
||||
.ok_or(format!("failed to find QMDL store entry for {}", name))?;
|
||||
let analysis_file = qmdl_store
|
||||
.clear_and_open_entry_analysis(entry_index)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))?;
|
||||
let qmdl_file = qmdl_store
|
||||
.open_entry_qmdl(entry_index)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))?;
|
||||
|
||||
(analysis_file, qmdl_file, entry_index)
|
||||
};
|
||||
|
||||
let mut analysis_writer =
|
||||
AnalysisWriter::new(analysis_file, enable_dummy_analyzer, analyzer_config)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))?;
|
||||
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::pin!(qmdl_reader
|
||||
.as_stream()
|
||||
.try_filter(|container| future::ready(container.data_type == DataType::UserSpace)));
|
||||
|
||||
info!("Starting analysis for {}...", name);
|
||||
while let Some(container) = qmdl_stream
|
||||
.try_next()
|
||||
.await
|
||||
.expect("failed getting QMDL container")
|
||||
{
|
||||
let (size_bytes, _) = analysis_writer
|
||||
.analyze(container)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))?;
|
||||
debug!("{} analysis: {} bytes written", name, size_bytes);
|
||||
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||
qmdl_store
|
||||
.update_entry_analysis_size(entry_index, size_bytes)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))?;
|
||||
}
|
||||
|
||||
analysis_writer
|
||||
.close()
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))?;
|
||||
info!("Analysis for {} complete!", name);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run_analysis_thread(
|
||||
task_tracker: &TaskTracker,
|
||||
mut analysis_rx: Receiver<AnalysisCtrlMessage>,
|
||||
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
|
||||
analysis_status_lock: Arc<RwLock<AnalysisStatus>>,
|
||||
enable_dummy_analyzer: bool,
|
||||
analyzer_config: AnalyzerConfig,
|
||||
) {
|
||||
task_tracker.spawn(async move {
|
||||
loop {
|
||||
match analysis_rx.recv().await {
|
||||
Some(AnalysisCtrlMessage::NewFilesQueued) => {
|
||||
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,
|
||||
&analyzer_config,
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!("failed to analyze {}: {}", name, err);
|
||||
}
|
||||
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,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn get_analysis_status(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
) -> Result<Json<AnalysisStatus>, (StatusCode, String)> {
|
||||
Ok(Json(state.analysis_status_lock.read().await.clone()))
|
||||
}
|
||||
|
||||
fn queue_qmdl(name: &str, analysis_status: &mut RwLockWriteGuard<AnalysisStatus>) -> bool {
|
||||
if analysis_status.queued.iter().any(|n| n == name)
|
||||
|| analysis_status.running.iter().any(|n| n == name)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
analysis_status.queued.push(name.to_string());
|
||||
true
|
||||
}
|
||||
|
||||
pub async fn start_analysis(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
Path(qmdl_name): Path<String>,
|
||||
) -> Result<(StatusCode, Json<AnalysisStatus>), (StatusCode, String)> {
|
||||
let mut analysis_status = state.analysis_status_lock.write().await;
|
||||
let store = state.qmdl_store_lock.read().await;
|
||||
let queued = if qmdl_name.is_empty() {
|
||||
let mut entry_names: Vec<&str> = store
|
||||
.manifest
|
||||
.entries
|
||||
.iter()
|
||||
.map(|e| e.name.as_str())
|
||||
.collect();
|
||||
if let Some(current_entry) = store.current_entry {
|
||||
entry_names.remove(current_entry);
|
||||
}
|
||||
entry_names
|
||||
.iter()
|
||||
.any(|name| queue_qmdl(name, &mut analysis_status))
|
||||
} else {
|
||||
queue_qmdl(&qmdl_name, &mut analysis_status)
|
||||
};
|
||||
if queued {
|
||||
state
|
||||
.analysis_sender
|
||||
.send(AnalysisCtrlMessage::NewFilesQueued)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("failed to queue new analysis files: {:?}", e),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Ok((StatusCode::ACCEPTED, Json(analysis_status.clone())))
|
||||
}
|
||||
177
bin/src/check.rs
@@ -1,31 +1,178 @@
|
||||
use std::{future, path::PathBuf, pin::pin};
|
||||
use rayhunter::{analysis::analyzer::Harness, diag::DataType, qmdl::QmdlReader};
|
||||
use tokio::fs::File;
|
||||
use clap::Parser;
|
||||
use futures::TryStreamExt;
|
||||
use log::{info, warn};
|
||||
use rayhunter::{
|
||||
analysis::analyzer::{AnalyzerConfig, 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(enable_dummy_analyzer: bool, qmdl_path: &str, show_skipped: bool) {
|
||||
let mut harness = Harness::new_with_config(&AnalyzerConfig::default());
|
||||
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 mut qmdl_reader = QmdlReader::new(qmdl_file, Some(file_size as usize));
|
||||
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")
|
||||
{
|
||||
let row = harness.analyze_qmdl_messages(container);
|
||||
total_messages += 1;
|
||||
for reason in row.skipped_message_reasons {
|
||||
*skipped_reasons.entry(reason).or_insert(0) += 1;
|
||||
skipped += 1;
|
||||
}
|
||||
for analysis in row.analysis {
|
||||
for maybe_event in analysis.events {
|
||||
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 {
|
||||
info!("{}: messages skipped:", qmdl_path);
|
||||
for (reason, count) in skipped_reasons.iter() {
|
||||
info!(" - {}: \"{}\"", count, reason);
|
||||
}
|
||||
}
|
||||
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)
|
||||
//Filter out a stupid massive amount of uneccesary warnings from hampi about undecoded extensions
|
||||
.with_module_level("asn1_codecs", log::LevelFilter::Error)
|
||||
.init()
|
||||
.unwrap();
|
||||
info!("Analyzers:");
|
||||
|
||||
let mut harness = Harness::new_with_all_analyzers();
|
||||
let mut harness = Harness::new_with_config(&AnalyzerConfig::default());
|
||||
if args.enable_dummy_analyzer {
|
||||
harness.add_analyzer(Box::new(dummy_analyzer::TestAnalyzer { count: 0 }));
|
||||
}
|
||||
for analyzer in harness.get_metadata().analyzers {
|
||||
info!(" - {}: {}", analyzer.name, analyzer.description);
|
||||
}
|
||||
|
||||
let qmdl_file = File::open(args.qmdl_path).await.expect("failed to open QMDL file");
|
||||
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()
|
||||
.try_filter(|container| future::ready(container.data_type == DataType::UserSpace)));
|
||||
println!("{}\n", serde_json::to_string(&harness.get_metadata()).expect("failed to serialize report metadata"));
|
||||
while let Some(container) = qmdl_stream.try_next().await.expect("failed getting QMDL container") {
|
||||
let row = harness.analyze_qmdl_messages(container);
|
||||
println!("{}\n", serde_json::to_string(&row).expect("failed to serialize row"));
|
||||
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") {
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use rayhunter::analysis::analyzer::AnalyzerConfig;
|
||||
|
||||
use crate::error::RayhunterError;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ConfigFile {
|
||||
qmdl_store_path: Option<String>,
|
||||
port: Option<u16>,
|
||||
readonly_mode: Option<bool>,
|
||||
ui_level: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
pub qmdl_store_path: String,
|
||||
pub port: u16,
|
||||
pub readonly_mode: bool,
|
||||
pub debug_mode: bool,
|
||||
pub ui_level: u8,
|
||||
pub enable_dummy_analyzer: bool,
|
||||
pub colorblind_mode: bool,
|
||||
pub key_input_mode: u8,
|
||||
pub analyzers: AnalyzerConfig,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
@@ -23,23 +22,25 @@ impl Default for Config {
|
||||
Config {
|
||||
qmdl_store_path: "/data/rayhunter/qmdl".to_string(),
|
||||
port: 8080,
|
||||
readonly_mode: false,
|
||||
debug_mode: false,
|
||||
ui_level: 1,
|
||||
enable_dummy_analyzer: false,
|
||||
colorblind_mode: false,
|
||||
key_input_mode: 0,
|
||||
analyzers: AnalyzerConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_config<P>(path: P) -> Result<Config, RayhunterError> where P: AsRef<std::path::Path> {
|
||||
let mut config = Config::default();
|
||||
if let Ok(config_file) = std::fs::read_to_string(&path) {
|
||||
let parsed_config: ConfigFile = toml::from_str(&config_file)
|
||||
.map_err(RayhunterError::ConfigFileParsingError)?;
|
||||
if let Some(path) = parsed_config.qmdl_store_path { config.qmdl_store_path = path }
|
||||
if let Some(port) = parsed_config.port { config.port = port }
|
||||
if let Some(readonly_mode) = parsed_config.readonly_mode { config.readonly_mode = readonly_mode }
|
||||
if let Some(ui_level) = parsed_config.ui_level { config.ui_level = ui_level }
|
||||
pub async fn parse_config<P>(path: P) -> Result<Config, RayhunterError>
|
||||
where
|
||||
P: AsRef<std::path::Path>,
|
||||
{
|
||||
if let Ok(config_file) = tokio::fs::read_to_string(&path).await {
|
||||
Ok(toml::from_str(&config_file).map_err(RayhunterError::ConfigFileParsingError)?)
|
||||
} else {
|
||||
Ok(Config::default())
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub struct Args {
|
||||
|
||||
@@ -1,74 +1,88 @@
|
||||
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;
|
||||
|
||||
use crate::config::{parse_config, parse_args};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
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_config, get_qmdl, get_zip, serve_static, set_config, ServerState};
|
||||
use crate::stats::{get_qmdl_manifest, get_system_stats};
|
||||
|
||||
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 stats::get_qmdl_manifest;
|
||||
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 tokio::net::TcpListener;
|
||||
use tokio::select;
|
||||
use tokio::sync::mpsc::{self, Sender};
|
||||
use tokio::sync::oneshot::error::TryRecvError;
|
||||
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/zip/{name}", get(get_zip))
|
||||
.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("/api/config", get(get_config))
|
||||
.route("/api/config", post(set_config))
|
||||
.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
|
||||
// (i.e. user hit ctrl+c)
|
||||
async fn run_server(
|
||||
task_tracker: &TaskTracker,
|
||||
config: &config::Config,
|
||||
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
|
||||
state: Arc<ServerState>,
|
||||
server_shutdown_rx: oneshot::Receiver<()>,
|
||||
diag_device_sender: Sender<DiagDeviceCtrlMessage>
|
||||
) -> JoinHandle<()> {
|
||||
let state = Arc::new(ServerState {
|
||||
qmdl_store_lock,
|
||||
diag_device_ctrl_sender: diag_device_sender,
|
||||
readonly_mode: config.readonly_mode
|
||||
});
|
||||
|
||||
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", get(get_analysis_report))
|
||||
.route("/", get(|| async { Redirect::permanent("/index.html") }))
|
||||
.route("/*path", get(serve_static))
|
||||
.with_state(state);
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], config.port));
|
||||
info!("spinning up server");
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], state.config.port));
|
||||
let listener = TcpListener::bind(&addr).await.unwrap();
|
||||
let app = get_router().with_state(state);
|
||||
|
||||
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();
|
||||
})
|
||||
}
|
||||
|
||||
@@ -77,131 +91,212 @@ 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
|
||||
// readonly 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.readonly_mode) {
|
||||
(true, _) => Ok(RecordingStore::load(&config.qmdl_store_path).await?),
|
||||
(false, false) => Ok(RecordingStore::create(&config.qmdl_store_path).await?),
|
||||
(false, true) => Err(RayhunterError::NoStoreReadonlyMode(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?)
|
||||
}
|
||||
}
|
||||
|
||||
// Start a thread that'll track when user hits ctrl+c. When that happens,
|
||||
// trigger various cleanup tasks, including sending signals to other threads to
|
||||
// shutdown
|
||||
fn run_ctrl_c_thread(
|
||||
fn run_shutdown_thread(
|
||||
task_tracker: &TaskTracker,
|
||||
diag_device_sender: Sender<DiagDeviceCtrlMessage>,
|
||||
daemon_restart_rx: oneshot::Receiver<()>,
|
||||
should_restart_flag: Arc<AtomicBool>,
|
||||
server_shutdown_tx: oneshot::Sender<()>,
|
||||
ui_shutdown_tx: oneshot::Sender<()>,
|
||||
qmdl_store_lock: Arc<RwLock<RecordingStore>>
|
||||
maybe_ui_shutdown_tx: Option<oneshot::Sender<()>>,
|
||||
maybe_key_input_shutdown_tx: Option<oneshot::Sender<()>>,
|
||||
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
|
||||
analysis_tx: Sender<AnalysisCtrlMessage>,
|
||||
) -> JoinHandle<Result<(), RayhunterError>> {
|
||||
info!("create shutdown thread");
|
||||
|
||||
task_tracker.spawn(async move {
|
||||
match tokio::signal::ctrl_c().await {
|
||||
Ok(()) => {
|
||||
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||
if qmdl_store.current_entry.is_some() {
|
||||
info!("Closing current QMDL entry...");
|
||||
qmdl_store.close_current_entry().await?;
|
||||
info!("Done!");
|
||||
select! {
|
||||
res = tokio::signal::ctrl_c() => {
|
||||
if let Err(err) = res {
|
||||
error!("Unable to listen for shutdown signal: {}", err);
|
||||
}
|
||||
|
||||
server_shutdown_tx.send(())
|
||||
.expect("couldn't send server shutdown signal");
|
||||
info!("sending UI shutdown");
|
||||
ui_shutdown_tx.send(())
|
||||
.expect("couldn't send ui shutdown signal");
|
||||
diag_device_sender.send(DiagDeviceCtrlMessage::Exit).await
|
||||
.expect("couldn't send Exit message to diag thread");
|
||||
},
|
||||
Err(err) => {
|
||||
error!("Unable to listen for shutdown signal: {}", err);
|
||||
should_restart_flag.store(false, Ordering::Relaxed);
|
||||
}
|
||||
res = daemon_restart_rx => {
|
||||
if let Err(err) = res {
|
||||
error!("Unable to listen for shutdown signal: {}", err);
|
||||
}
|
||||
|
||||
should_restart_flag.store(true, Ordering::Relaxed);
|
||||
}
|
||||
};
|
||||
|
||||
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||
if qmdl_store.current_entry.is_some() {
|
||||
info!("Closing current QMDL entry...");
|
||||
qmdl_store.close_current_entry().await?;
|
||||
info!("Done!");
|
||||
}
|
||||
|
||||
server_shutdown_tx
|
||||
.send(())
|
||||
.expect("couldn't send server shutdown signal");
|
||||
if let Some(ui_shutdown_tx) = maybe_ui_shutdown_tx {
|
||||
let _ = ui_shutdown_tx.send(());
|
||||
}
|
||||
if let Some(key_input_shutdown_tx) = maybe_key_input_shutdown_tx {
|
||||
let _ = key_input_shutdown_tx.send(());
|
||||
}
|
||||
diag_device_sender
|
||||
.send(DiagDeviceCtrlMessage::Exit)
|
||||
.await
|
||||
.expect("couldn't send Exit message to diag thread");
|
||||
analysis_tx
|
||||
.send(AnalysisCtrlMessage::Exit)
|
||||
.await
|
||||
.expect("couldn't send Exit message to analysis thread");
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
async fn update_ui(task_tracker: &TaskTracker, config: &config::Config, mut ui_shutdown_rx: oneshot::Receiver<()>){
|
||||
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.");
|
||||
}
|
||||
|
||||
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 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(framebuffer::Color565::Green, 2);
|
||||
},
|
||||
};
|
||||
sleep(Duration::from_millis(100));
|
||||
}
|
||||
}).await.unwrap();
|
||||
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), RayhunterError> {
|
||||
env_logger::init();
|
||||
|
||||
let args = parse_args();
|
||||
let config = parse_config(&args.config_path)?;
|
||||
|
||||
loop {
|
||||
let config = parse_config(&args.config_path).await?;
|
||||
if !run_with_config(&args, config).await? {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_with_config(
|
||||
args: &config::Args,
|
||||
config: config::Config,
|
||||
) -> Result<bool, RayhunterError> {
|
||||
// TaskTrackers give us an interface to spawn tokio threads, and then
|
||||
// eventually await all of them ending
|
||||
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);
|
||||
if !config.readonly_mode {
|
||||
let mut dev = DiagDevice::new().await
|
||||
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;
|
||||
let mut maybe_key_input_shutdown_tx = None;
|
||||
if !config.debug_mode {
|
||||
let (ui_shutdown_tx, ui_shutdown_rx) = oneshot::channel();
|
||||
maybe_ui_shutdown_tx = Some(ui_shutdown_tx);
|
||||
let mut dev = DiagDevice::new()
|
||||
.await
|
||||
.map_err(RayhunterError::DiagInitError)?;
|
||||
dev.config_logs().await
|
||||
dev.config_logs()
|
||||
.await
|
||||
.map_err(RayhunterError::DiagInitError)?;
|
||||
|
||||
run_diag_read_thread(&task_tracker, dev, rx, qmdl_store_lock.clone());
|
||||
info!("Starting Diag Thread");
|
||||
run_diag_read_thread(
|
||||
&task_tracker,
|
||||
dev,
|
||||
diag_rx,
|
||||
ui_update_tx.clone(),
|
||||
qmdl_store_lock.clone(),
|
||||
analysis_tx.clone(),
|
||||
config.enable_dummy_analyzer,
|
||||
config.analyzers.clone(),
|
||||
);
|
||||
info!("Starting UI");
|
||||
display::update_ui(&task_tracker, &config, ui_shutdown_rx, ui_update_rx);
|
||||
|
||||
info!("Starting Key Input service");
|
||||
let (key_input_shutdown_tx, key_input_shutdown_rx) = oneshot::channel();
|
||||
maybe_key_input_shutdown_tx = Some(key_input_shutdown_tx);
|
||||
key_input::run_key_input_thread(
|
||||
&task_tracker,
|
||||
&config,
|
||||
diag_tx.clone(),
|
||||
key_input_shutdown_rx,
|
||||
);
|
||||
}
|
||||
let (ui_shutdown_tx, ui_shutdown_rx) = oneshot::channel();
|
||||
|
||||
let (daemon_restart_tx, daemon_restart_rx) = oneshot::channel::<()>();
|
||||
let (server_shutdown_tx, server_shutdown_rx) = oneshot::channel::<()>();
|
||||
run_ctrl_c_thread(&task_tracker, tx.clone(), server_shutdown_tx, ui_shutdown_tx, qmdl_store_lock.clone());
|
||||
run_server(&task_tracker, &config, qmdl_store_lock.clone(), server_shutdown_rx, tx).await;
|
||||
update_ui(&task_tracker, &config, ui_shutdown_rx).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,
|
||||
config.analyzers.clone(),
|
||||
);
|
||||
let should_restart_flag = Arc::new(AtomicBool::new(false));
|
||||
|
||||
run_shutdown_thread(
|
||||
&task_tracker,
|
||||
diag_tx.clone(),
|
||||
daemon_restart_rx,
|
||||
should_restart_flag.clone(),
|
||||
server_shutdown_tx,
|
||||
maybe_ui_shutdown_tx,
|
||||
maybe_key_input_shutdown_tx,
|
||||
qmdl_store_lock.clone(),
|
||||
analysis_tx.clone(),
|
||||
);
|
||||
let state = Arc::new(ServerState {
|
||||
config_path: args.config_path.clone(),
|
||||
config,
|
||||
qmdl_store_lock: qmdl_store_lock.clone(),
|
||||
diag_device_ctrl_sender: diag_tx,
|
||||
ui_update_sender: ui_update_tx,
|
||||
analysis_status_lock,
|
||||
analysis_sender: analysis_tx,
|
||||
daemon_restart_tx: Arc::new(RwLock::new(Some(daemon_restart_tx))),
|
||||
});
|
||||
run_server(&task_tracker, state, server_shutdown_rx).await;
|
||||
|
||||
task_tracker.close();
|
||||
task_tracker.wait().await;
|
||||
|
||||
Ok(())
|
||||
info!("see you space cowboy...");
|
||||
Ok(should_restart_flag.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
#[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();
|
||||
}
|
||||
}
|
||||
|
||||
282
bin/src/diag.rs
@@ -2,113 +2,100 @@ use std::pin::pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::State;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::header::CONTENT_TYPE;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use rayhunter::analysis::analyzer::Harness;
|
||||
use rayhunter::diag::{DataType, MessagesContainer};
|
||||
use futures::{StreamExt, TryStreamExt};
|
||||
use log::{debug, error, info, warn};
|
||||
use rayhunter::analysis::analyzer::AnalyzerConfig;
|
||||
use rayhunter::diag::DataType;
|
||||
use rayhunter::diag_device::DiagDevice;
|
||||
use serde::Serialize;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use rayhunter::qmdl::QmdlWriter;
|
||||
use log::{debug, error, info};
|
||||
use tokio::fs::File;
|
||||
use tokio::io::{BufWriter, AsyncWriteExt};
|
||||
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::qmdl_store::RecordingStore;
|
||||
use crate::analysis::{AnalysisCtrlMessage, AnalysisWriter};
|
||||
use crate::display;
|
||||
use crate::qmdl_store::{RecordingStore, RecordingStoreError};
|
||||
use crate::server::ServerState;
|
||||
|
||||
pub enum DiagDeviceCtrlMessage {
|
||||
StopRecording,
|
||||
StartRecording((QmdlWriter<File>, File)),
|
||||
StartRecording,
|
||||
Exit,
|
||||
}
|
||||
|
||||
struct AnalysisWriter {
|
||||
writer: BufWriter<File>,
|
||||
harness: Harness,
|
||||
bytes_written: usize,
|
||||
}
|
||||
|
||||
// We write our analysis results to a file immediately to minimize the amount of
|
||||
// state Rayhunter has to keep track of in memory. The analysis file's format is
|
||||
// Newline Delimited JSON
|
||||
// (https://docs.mulesoft.com/dataweave/latest/dataweave-formats-ndjson), which
|
||||
// lets us simply append new rows to the end without parsing the entire JSON
|
||||
// object beforehand.
|
||||
impl AnalysisWriter {
|
||||
pub async fn new(file: File) -> Result<Self, std::io::Error> {
|
||||
let mut result = Self {
|
||||
writer: BufWriter::new(file),
|
||||
harness: Harness::new_with_all_analyzers(),
|
||||
bytes_written: 0,
|
||||
};
|
||||
let metadata = result.harness.get_metadata();
|
||||
result.write(&metadata).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// 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, std::io::Error> {
|
||||
let row = self.harness.analyze_qmdl_messages(container);
|
||||
if !row.is_empty() {
|
||||
self.write(&row).await?;
|
||||
}
|
||||
Ok(self.bytes_written)
|
||||
}
|
||||
|
||||
async fn write<T: Serialize>(&mut self, value: &T) -> Result<(), std::io::Error> {
|
||||
let mut value_str = serde_json::to_string(value).unwrap();
|
||||
value_str.push('\n');
|
||||
self.bytes_written += value_str.len();
|
||||
self.writer.write_all(value_str.as_bytes()).await?;
|
||||
self.writer.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Flushes any pending I/O to disk before dropping the writer
|
||||
pub async fn close(mut self) -> Result<(), std::io::Error> {
|
||||
self.writer.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_diag_read_thread(
|
||||
task_tracker: &TaskTracker,
|
||||
mut dev: DiagDevice,
|
||||
mut qmdl_file_rx: Receiver<DiagDeviceCtrlMessage>,
|
||||
qmdl_store_lock: Arc<RwLock<RecordingStore>>
|
||||
ui_update_sender: Sender<display::DisplayState>,
|
||||
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
|
||||
analysis_sender: Sender<AnalysisCtrlMessage>,
|
||||
enable_dummy_analyzer: bool,
|
||||
analyzer_config: AnalyzerConfig,
|
||||
) {
|
||||
task_tracker.spawn(async move {
|
||||
let (initial_qmdl_file, initial_analysis_file) = qmdl_store_lock.write().await.new_entry().await.expect("failed creating QMDL file entry");
|
||||
let mut maybe_qmdl_writer: Option<QmdlWriter<File>> = Some(QmdlWriter::new(initial_qmdl_file));
|
||||
let mut diag_stream = pin!(dev.as_stream().into_stream());
|
||||
let mut maybe_analysis_writer = Some(AnalysisWriter::new(initial_analysis_file).await
|
||||
let mut maybe_analysis_writer = Some(AnalysisWriter::new(initial_analysis_file, enable_dummy_analyzer, &analyzer_config).await
|
||||
.expect("failed to create analysis writer"));
|
||||
loop {
|
||||
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).await
|
||||
|
||||
maybe_analysis_writer = Some(AnalysisWriter::new(new_analysis_file, enable_dummy_analyzer, &analyzer_config).await
|
||||
.expect("failed to write to analysis file"));
|
||||
|
||||
if let Err(e) = ui_update_sender.send(display::DisplayState::Recording).await {
|
||||
warn!("couldn't send ui update message: {}", e);
|
||||
}
|
||||
},
|
||||
Some(DiagDeviceCtrlMessage::StopRecording) => {
|
||||
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||
if let Some((_, entry)) = qmdl_store.get_current_entry() {
|
||||
if let Err(e) = analysis_sender
|
||||
.send(AnalysisCtrlMessage::RecordingFinished(
|
||||
entry.name.to_string(),
|
||||
))
|
||||
.await {
|
||||
warn!("couldn't send analysis message: {}", e);
|
||||
}
|
||||
}
|
||||
if let Err(e) = qmdl_store.close_current_entry().await {
|
||||
error!("couldn't close current entry: {}", e);
|
||||
}
|
||||
|
||||
maybe_qmdl_writer = None;
|
||||
if let Some(analysis_writer) = maybe_analysis_writer {
|
||||
analysis_writer.close().await.expect("failed to close analysis writer");
|
||||
}
|
||||
maybe_analysis_writer = None;
|
||||
|
||||
if let Err(e) = ui_update_sender.send(display::DisplayState::Paused).await {
|
||||
warn!("couldn't send ui update message: {}", e);
|
||||
}
|
||||
},
|
||||
// None means all the Senders have been dropped, so it's
|
||||
// time to go
|
||||
@@ -143,11 +130,17 @@ pub fn run_diag_read_thread(
|
||||
}
|
||||
|
||||
if let Some(analysis_writer) = maybe_analysis_writer.as_mut() {
|
||||
let analysis_file_len = analysis_writer.analyze(container).await
|
||||
let analysis_output = analysis_writer.analyze(container).await
|
||||
.expect("failed to analyze container");
|
||||
let (analysis_file_len, heuristic_warning) = analysis_output;
|
||||
if heuristic_warning {
|
||||
info!("a heuristic triggered on this run!");
|
||||
ui_update_sender.send(display::DisplayState::WarningDetected).await
|
||||
.expect("couldn't send ui update message: {}");
|
||||
}
|
||||
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");
|
||||
}
|
||||
},
|
||||
@@ -162,40 +155,147 @@ pub fn run_diag_read_thread(
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn start_recording(State(state): State<Arc<ServerState>>) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||
if state.readonly_mode {
|
||||
return Err((StatusCode::FORBIDDEN, "server is in readonly mode".to_string()));
|
||||
pub async fn start_recording(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||
if state.config.debug_mode {
|
||||
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
|
||||
}
|
||||
let 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
|
||||
.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.readonly_mode {
|
||||
return Err((StatusCode::FORBIDDEN, "server is in readonly mode".to_string()));
|
||||
pub async fn stop_recording(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||
if state.config.debug_mode {
|
||||
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
|
||||
}
|
||||
let 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
|
||||
.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 get_analysis_report(State(state): State<Arc<ServerState>>) -> Result<Response, (StatusCode, String)> {
|
||||
pub async fn delete_recording(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
Path(qmdl_name): Path<String>,
|
||||
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||
if state.config.debug_mode {
|
||||
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
|
||||
}
|
||||
let mut qmdl_store = state.qmdl_store_lock.write().await;
|
||||
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 delete_all_recordings(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||
if state.config.debug_mode {
|
||||
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
|
||||
}
|
||||
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.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)> {
|
||||
let qmdl_store = state.qmdl_store_lock.read().await;
|
||||
let Some(entry) = qmdl_store.get_current_entry() else {
|
||||
return Err((
|
||||
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),
|
||||
))?
|
||||
};
|
||||
let analysis_file = qmdl_store.open_entry_analysis(entry).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));
|
||||
}
|
||||
});
|
||||
}
|
||||
27
bin/src/display/mod.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
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;
|
||||
|
||||
#[cfg(feature = "wingtech")]
|
||||
mod wingtech;
|
||||
#[cfg(feature = "wingtech")]
|
||||
pub use wingtech::update_ui;
|
||||
|
||||
pub enum DisplayState {
|
||||
Recording,
|
||||
Paused,
|
||||
WarningDetected,
|
||||
}
|
||||
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
|
||||
]
|
||||
);
|
||||
}
|
||||
54
bin/src/display/wingtech.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
/// Display support for the Wingtech CT2MHS01 hotspot.
|
||||
///
|
||||
/// Tested on (from `/etc/wt_version`):
|
||||
/// WT_INNER_VERSION=SW_Q89323AA1_V057_M10_CRICKET_USR_MP
|
||||
/// WT_PRODUCTION_VERSION=CT2MHS01_0.04.55
|
||||
/// WT_HARDWARE_VERSION=89323_1_20
|
||||
use crate::config;
|
||||
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
|
||||
use crate::display::DisplayState;
|
||||
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
const FB_PATH: &str = "/dev/fb0";
|
||||
|
||||
#[derive(Copy, Clone, Default)]
|
||||
struct Framebuffer;
|
||||
|
||||
impl GenericFramebuffer for Framebuffer {
|
||||
fn dimensions(&self) -> Dimensions {
|
||||
Dimensions {
|
||||
height: 128,
|
||||
width: 160,
|
||||
}
|
||||
}
|
||||
|
||||
fn write_buffer(&mut self, buffer: &[(u8, u8, u8)]) {
|
||||
let mut raw_buffer = Vec::new();
|
||||
for (r, g, b) in buffer {
|
||||
let mut rgb565: u16 = (*r as u16 & 0b11111000) << 8;
|
||||
rgb565 |= (*g as u16 & 0b11111100) << 3;
|
||||
rgb565 |= (*b as u16) >> 3;
|
||||
raw_buffer.extend(rgb565.to_le_bytes());
|
||||
}
|
||||
|
||||
std::fs::write(FB_PATH, &raw_buffer).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_ui(
|
||||
task_tracker: &TaskTracker,
|
||||
config: &config::Config,
|
||||
ui_shutdown_rx: oneshot::Receiver<()>,
|
||||
ui_update_rx: Receiver<DisplayState>,
|
||||
) {
|
||||
generic_framebuffer::update_ui(
|
||||
task_tracker,
|
||||
config,
|
||||
Framebuffer,
|
||||
ui_shutdown_rx,
|
||||
ui_update_rx,
|
||||
)
|
||||
}
|
||||
51
bin/src/dummy_analyzer.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use rayhunter::telcom_parser::lte_rrc::{PCCH_MessageType, PCCH_MessageType_c1, PagingUE_Identity};
|
||||
|
||||
use rayhunter::analysis::analyzer::{Analyzer, Event, EventType, Severity};
|
||||
use rayhunter::analysis::information_element::{InformationElement, LteInformationElement};
|
||||
|
||||
pub struct TestAnalyzer {
|
||||
pub count: i32,
|
||||
}
|
||||
|
||||
impl Analyzer for TestAnalyzer {
|
||||
fn get_name(&self) -> Cow<str> {
|
||||
Cow::from("Example Analyzer")
|
||||
}
|
||||
|
||||
fn get_description(&self) -> Cow<str> {
|
||||
Cow::from("Always returns true, if you are seeing this you are either a developer or you are about to have problems.")
|
||||
}
|
||||
|
||||
fn analyze_information_element(&mut self, ie: &InformationElement) -> Option<Event> {
|
||||
self.count += 1;
|
||||
if self.count % 100 == 0 {
|
||||
return Some(Event {
|
||||
event_type: EventType::Informational,
|
||||
message: "multiple of 100 events processed".to_string(),
|
||||
});
|
||||
}
|
||||
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;
|
||||
};
|
||||
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,
|
||||
},
|
||||
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}")]
|
||||
@@ -13,6 +13,6 @@ pub enum RayhunterError{
|
||||
TokioError(#[from] tokio::io::Error),
|
||||
#[error("QmdlStore error: {0}")]
|
||||
QmdlStoreError(#[from] RecordingStoreError),
|
||||
#[error("No QMDL store found at path {0}, but can't create a new one due to readonly mode")]
|
||||
NoStoreReadonlyMode(String),
|
||||
#[error("No QMDL store found at path {0}, but can't create a new one due to debug mode")]
|
||||
NoStoreDebugMode(String),
|
||||
}
|
||||
|
||||
@@ -1,92 +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)]
|
||||
pub enum Color565 {
|
||||
Red = 0b1111100000000000,
|
||||
Green = 0b0000011111100000,
|
||||
Blue = 0b0000000000011111,
|
||||
White = 0b1111111111111111,
|
||||
Black = 0b0000000000000000,
|
||||
Cyan = 0b0000011111111111,
|
||||
Yellow = 0b1111111111100000,
|
||||
Pink = 0b1111010010011111,
|
||||
}
|
||||
|
||||
#[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();
|
||||
}
|
||||
}
|
||||
131
bin/src/key_input.rs
Normal file
@@ -0,0 +1,131 @@
|
||||
use log::{error, info};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::fs::File;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::sync::mpsc::Sender;
|
||||
use tokio::sync::oneshot;
|
||||
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>,
|
||||
mut ui_shutdown_rx: oneshot::Receiver<()>,
|
||||
) {
|
||||
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;
|
||||
let mut last_event_time: Option<Instant> = None;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = &mut ui_shutdown_rx => {
|
||||
info!("received key input shutdown");
|
||||
return;
|
||||
}
|
||||
result = file.read_exact(&mut buffer) => {
|
||||
if let Err(e) = result {
|
||||
error!("failed to read key input: {}", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let event = parse_event(buffer);
|
||||
|
||||
let now = Instant::now();
|
||||
|
||||
// On orbic it was observed that pressing the power button can trigger many successive
|
||||
// events. Drop events that are too close together.
|
||||
if let Some(last_time) = last_event_time {
|
||||
if now.duration_since(last_time) < Duration::from_millis(50) {
|
||||
last_event_time = Some(now);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
last_event_time = Some(now);
|
||||
|
||||
match event {
|
||||
Event::KeyUp => {
|
||||
if let Some(last_keyup_instant) = last_keyup {
|
||||
let elapsed = now.duration_since(last_keyup_instant);
|
||||
|
||||
if elapsed >= Duration::from_millis(100)
|
||||
&& elapsed <= Duration::from_millis(800)
|
||||
{
|
||||
if let Err(e) = diag_tx.send(DiagDeviceCtrlMessage::StopRecording).await
|
||||
{
|
||||
error!("Failed to send StopRecording: {}", e);
|
||||
}
|
||||
if let Err(e) =
|
||||
diag_tx.send(DiagDeviceCtrlMessage::StartRecording).await
|
||||
{
|
||||
error!("Failed to send StartRecording: {}", e);
|
||||
}
|
||||
last_keyup = None;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
last_keyup = Some(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,62 +1,53 @@
|
||||
use crate::ServerState;
|
||||
|
||||
use anyhow::Error;
|
||||
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 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 tokio::io::duplex;
|
||||
use tokio_util::io::ReaderStream;
|
||||
use std::{future, pin::pin};
|
||||
use std::sync::Arc;
|
||||
use log::error;
|
||||
use futures::TryStreamExt;
|
||||
use tokio::io::{duplex, AsyncRead, AsyncWrite};
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
// Streams a pcap file chunk-by-chunk to the client by reading the QMDL data
|
||||
// written so far. This is done by spawning a thread which streams chunks of
|
||||
// pcap data to a channel that's piped to the client.
|
||||
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(mut qmdl_name): Path<String>,
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
let qmdl_store = state.qmdl_store_lock.read().await;
|
||||
let entry = qmdl_store.entry_for_name(&qmdl_name)
|
||||
.ok_or((StatusCode::NOT_FOUND, format!("couldn't find qmdl file with name {}", qmdl_name)))?;
|
||||
if qmdl_name.ends_with("pcapng") {
|
||||
qmdl_name = qmdl_name.trim_end_matches(".pcapng").to_string();
|
||||
}
|
||||
let (entry_index, entry) = qmdl_store.entry_for_name(&qmdl_name).ok_or((
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("couldn't find manifest entry 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_file = qmdl_store.open_entry_qmdl(&entry).await
|
||||
let qmdl_size_bytes = entry.qmdl_size_bytes;
|
||||
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)
|
||||
let (reader, writer) = duplex(1024);
|
||||
let mut pcap_writer = GsmtapPcapWriter::new(writer).await.unwrap();
|
||||
pcap_writer.write_iface_header().await.unwrap();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut reader = QmdlReader::new(qmdl_file, Some(entry.qmdl_size_bytes));
|
||||
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") {
|
||||
for maybe_msg in container.into_messages() {
|
||||
match maybe_msg {
|
||||
Ok(msg) => {
|
||||
let maybe_gsmtap_msg = gsmtap_parser::parse(msg)
|
||||
.expect("error parsing gsmtap message");
|
||||
if let Some((timestamp, gsmtap_msg)) = maybe_gsmtap_msg {
|
||||
pcap_writer.write_gsmtap_message(gsmtap_msg, timestamp).await
|
||||
.expect("error writing pcap packet");
|
||||
}
|
||||
},
|
||||
Err(e) => error!("error parsing message: {:?}", e),
|
||||
}
|
||||
}
|
||||
if let Err(e) = generate_pcap_data(writer, qmdl_file, qmdl_size_bytes).await {
|
||||
error!("failed to generate PCAP: {:?}", e);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -64,3 +55,39 @@ pub async fn get_pcap(State(state): State<Arc<ServerState>>, Path(qmdl_name): Pa
|
||||
let body = Body::from_stream(ReaderStream::new(reader));
|
||||
Ok((headers, body).into_response())
|
||||
}
|
||||
|
||||
pub async fn generate_pcap_data<R, W>(
|
||||
writer: W,
|
||||
qmdl_file: R,
|
||||
qmdl_size_bytes: usize,
|
||||
) -> Result<(), Error>
|
||||
where
|
||||
W: AsyncWrite + Unpin + Send,
|
||||
R: AsyncRead + Unpin,
|
||||
{
|
||||
let mut pcap_writer = GsmtapPcapWriter::new(writer).await?;
|
||||
pcap_writer.write_iface_header().await?;
|
||||
|
||||
let mut reader = QmdlReader::new(qmdl_file, Some(qmdl_size_bytes));
|
||||
while let Some(container) = reader.get_next_messages_container().await? {
|
||||
if container.data_type != DataType::UserSpace {
|
||||
continue;
|
||||
}
|
||||
|
||||
for maybe_msg in container.into_messages() {
|
||||
match maybe_msg {
|
||||
Ok(msg) => {
|
||||
let maybe_gsmtap_msg = gsmtap_parser::parse(msg)?;
|
||||
if let Some((timestamp, gsmtap_msg)) = maybe_gsmtap_msg {
|
||||
pcap_writer
|
||||
.write_gsmtap_message(gsmtap_msg, timestamp)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Err(e) => error!("error parsing message: {:?}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
use std::path::{PathBuf, Path};
|
||||
use thiserror::Error;
|
||||
use tokio::{fs::{self, File, try_exists}, io::AsyncWriteExt};
|
||||
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},
|
||||
io::AsyncWriteExt,
|
||||
};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
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}")]
|
||||
@@ -19,7 +29,7 @@ pub enum RecordingStoreError {
|
||||
#[error("Couldn't write manifest file: {0}")]
|
||||
WriteManifestError(tokio::io::Error),
|
||||
#[error("Couldn't parse QMDL store manifest file: {0}")]
|
||||
ParseManifestError(toml::de::Error)
|
||||
ParseManifestError(toml::de::Error),
|
||||
}
|
||||
|
||||
pub struct RecordingStore {
|
||||
@@ -40,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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,16 +87,26 @@ impl ManifestEntry {
|
||||
impl RecordingStore {
|
||||
// Returns whether a directory with a "manifest.toml" exists at the given
|
||||
// path (though doesn't check if that manifest is valid)
|
||||
pub async fn exists<P>(path: P) -> Result<bool, RecordingStoreError> where P: AsRef<Path> {
|
||||
pub async fn exists<P>(path: P) -> Result<bool, RecordingStoreError>
|
||||
where
|
||||
P: AsRef<Path>,
|
||||
{
|
||||
let manifest_path = path.as_ref().join("manifest.toml");
|
||||
let dir_exists = try_exists(path).await.map_err(RecordingStoreError::OpenDirError)?;
|
||||
let manifest_exists = try_exists(manifest_path).await.map_err(RecordingStoreError::ReadManifestError)?;
|
||||
let dir_exists = try_exists(path)
|
||||
.await
|
||||
.map_err(RecordingStoreError::OpenDirError)?;
|
||||
let manifest_exists = try_exists(manifest_path)
|
||||
.await
|
||||
.map_err(RecordingStoreError::ReadManifestError)?;
|
||||
Ok(dir_exists && manifest_exists)
|
||||
}
|
||||
|
||||
// Loads an existing RecordingStore at the given path. Errors if no store exists,
|
||||
// or if it's malformed.
|
||||
pub async fn load<P>(path: P) -> Result<Self, RecordingStoreError> where P: AsRef<Path> {
|
||||
pub async fn load<P>(path: P) -> Result<Self, RecordingStoreError>
|
||||
where
|
||||
P: AsRef<Path>,
|
||||
{
|
||||
let path: PathBuf = path.as_ref().to_path_buf();
|
||||
let manifest = RecordingStore::read_manifest(&path).await?;
|
||||
Ok(RecordingStore {
|
||||
@@ -91,26 +118,35 @@ impl RecordingStore {
|
||||
|
||||
// Creates a new RecordingStore at the given path. This involves creating a dir
|
||||
// and writing an empty manifest.
|
||||
pub async fn create<P>(path: P) -> Result<Self, RecordingStoreError> where P: AsRef<Path> {
|
||||
let manifest_path = path.as_ref().join("manifest.toml");
|
||||
fs::create_dir_all(&path).await
|
||||
pub async fn create<P>(path: P) -> Result<Self, RecordingStoreError>
|
||||
where
|
||||
P: AsRef<Path>,
|
||||
{
|
||||
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 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
|
||||
|
||||
let mut store = RecordingStore {
|
||||
path: path.as_ref().to_owned(),
|
||||
manifest: Manifest {
|
||||
entries: Vec::new(),
|
||||
},
|
||||
current_entry: None,
|
||||
};
|
||||
|
||||
store.write_manifest().await?;
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
async fn read_manifest<P>(path: P) -> Result<Manifest, RecordingStoreError> where P: AsRef<Path> {
|
||||
async fn read_manifest<P>(path: P) -> Result<Manifest, RecordingStoreError>
|
||||
where
|
||||
P: AsRef<Path>,
|
||||
{
|
||||
let manifest_path = path.as_ref().join("manifest.toml");
|
||||
let file_contents = fs::read_to_string(&manifest_path).await
|
||||
let file_contents = fs::read_to_string(&manifest_path)
|
||||
.await
|
||||
.map_err(RecordingStoreError::ReadManifestError)?;
|
||||
toml::from_str(&file_contents)
|
||||
.map_err(RecordingStoreError::ParseManifestError)
|
||||
toml::from_str(&file_contents).map_err(RecordingStoreError::ParseManifestError)
|
||||
}
|
||||
|
||||
// Closes the current entry (if needed), creates a new entry based on the
|
||||
@@ -123,16 +159,12 @@ 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).await
|
||||
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).await
|
||||
let analysis_file = File::create(&analysis_filepath)
|
||||
.await
|
||||
.map_err(RecordingStoreError::CreateFileError)?;
|
||||
self.manifest.entries.push(new_entry);
|
||||
self.current_entry = Some(self.manifest.entries.len() - 1);
|
||||
@@ -141,74 +173,185 @@ impl RecordingStore {
|
||||
}
|
||||
|
||||
// Returns the corresponding QMDL file for a given entry
|
||||
pub async fn open_entry_qmdl(&self, entry: &ManifestEntry) -> Result<File, RecordingStoreError> {
|
||||
File::open(entry.get_qmdl_filepath(&self.path)).await
|
||||
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
|
||||
.map_err(RecordingStoreError::ReadFileError)
|
||||
}
|
||||
|
||||
// Returns the corresponding QMDL file for a given entry
|
||||
pub async fn open_entry_analysis(&self, entry: &ManifestEntry) -> Result<File, RecordingStoreError> {
|
||||
File::open(entry.get_analysis_filepath(&self.path)).await
|
||||
pub async fn open_entry_analysis(
|
||||
&self,
|
||||
entry_index: usize,
|
||||
) -> Result<File, RecordingStoreError> {
|
||||
let entry = &self.manifest.entries[entry_index];
|
||||
File::open(entry.get_analysis_filepath(&self.path))
|
||||
.await
|
||||
.map_err(RecordingStoreError::ReadFileError)
|
||||
}
|
||||
|
||||
pub async fn clear_and_open_entry_analysis(
|
||||
&mut self,
|
||||
entry_index: usize,
|
||||
) -> Result<File, RecordingStoreError> {
|
||||
let entry = &self.manifest.entries[entry_index];
|
||||
let file = OpenOptions::new()
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(entry.get_analysis_filepath(&self.path))
|
||||
.await
|
||||
.map_err(RecordingStoreError::ReadFileError)?;
|
||||
self.update_entry_analysis_size(entry_index, 0).await?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
// Unsets the current entry
|
||||
pub async fn close_current_entry(&mut self) -> Result<(), RecordingStoreError> {
|
||||
match self.current_entry {
|
||||
Some(_) => {
|
||||
self.current_entry = None;
|
||||
Ok(())
|
||||
},
|
||||
None => Err(RecordingStoreError::NoCurrentEntry)
|
||||
}
|
||||
None => Err(RecordingStoreError::NoCurrentEntry),
|
||||
}
|
||||
}
|
||||
|
||||
// Sets the given entry's size and updates the last_message_time to now, updating the manifest
|
||||
pub async fn update_entry_qmdl_size(&mut self, entry_index: usize, size_bytes: usize) -> Result<(), RecordingStoreError> {
|
||||
pub async fn update_entry_qmdl_size(
|
||||
&mut self,
|
||||
entry_index: usize,
|
||||
size_bytes: usize,
|
||||
) -> Result<(), RecordingStoreError> {
|
||||
self.manifest.entries[entry_index].qmdl_size_bytes = size_bytes;
|
||||
self.manifest.entries[entry_index].last_message_time = Some(Local::now());
|
||||
self.write_manifest().await
|
||||
}
|
||||
|
||||
// Sets the given entry's analysis file size
|
||||
pub async fn update_entry_analysis_size(&mut self, entry_index: usize, size_bytes: usize) -> Result<(), RecordingStoreError> {
|
||||
pub async fn update_entry_analysis_size(
|
||||
&mut self,
|
||||
entry_index: usize,
|
||||
size_bytes: usize,
|
||||
) -> Result<(), RecordingStoreError> {
|
||||
self.manifest.entries[entry_index].analysis_size_bytes = size_bytes;
|
||||
self.write_manifest().await
|
||||
}
|
||||
|
||||
async fn write_manifest(&mut self) -> Result<(), RecordingStoreError> {
|
||||
let mut manifest_file = File::options()
|
||||
.write(true)
|
||||
.open(self.path.join("manifest.toml")).await
|
||||
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.write_all(manifest_contents.as_bytes()).await
|
||||
|
||||
let manifest_contents =
|
||||
toml::to_string_pretty(&self.manifest).expect("failed to serialize manifest");
|
||||
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<ManifestEntry> {
|
||||
self.manifest.entries.iter()
|
||||
.find(|entry| entry.name == name)
|
||||
.cloned()
|
||||
pub fn entry_for_name(&self, name: &str) -> Option<(usize, &ManifestEntry)> {
|
||||
let entry_index = self
|
||||
.manifest
|
||||
.entries
|
||||
.iter()
|
||||
.position(|entry| entry.name == name)?;
|
||||
Some((entry_index, &self.manifest.entries[entry_index]))
|
||||
}
|
||||
|
||||
pub fn get_current_entry(&self) -> Option<&ManifestEntry> {
|
||||
pub fn get_current_entry(&self) -> Option<(usize, &ManifestEntry)> {
|
||||
let entry_index = self.current_entry?;
|
||||
self.manifest.entries.get(entry_index)
|
||||
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)]
|
||||
mod tests {
|
||||
use tempdir::TempDir;
|
||||
use super::*;
|
||||
use tempfile::{Builder, TempDir};
|
||||
|
||||
fn make_temp_dir() -> TempDir {
|
||||
Builder::new().prefix("qmdl_store_test").tempdir().unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_from_empty_dir() {
|
||||
let dir = TempDir::new("qmdl_store_test").unwrap();
|
||||
let dir = make_temp_dir();
|
||||
assert!(!RecordingStore::exists(dir.path()).await.unwrap());
|
||||
let _created_store = RecordingStore::create(dir.path()).await.unwrap();
|
||||
assert!(RecordingStore::exists(dir.path()).await.unwrap());
|
||||
@@ -218,26 +361,56 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_creating_updating_and_closing_entries() {
|
||||
let dir = TempDir::new("qmdl_store_test").unwrap();
|
||||
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();
|
||||
assert_eq!(RecordingStore::read_manifest(dir.path()).await.unwrap(), store.manifest);
|
||||
assert!(store.manifest.entries[entry_index].last_message_time.is_none());
|
||||
assert_eq!(
|
||||
RecordingStore::read_manifest(dir.path()).await.unwrap(),
|
||||
store.manifest
|
||||
);
|
||||
assert!(store.manifest.entries[entry_index]
|
||||
.last_message_time
|
||||
.is_none());
|
||||
|
||||
store.update_entry_qmdl_size(entry_index, 1000).await.unwrap();
|
||||
let entry = store.entry_for_name(&store.manifest.entries[entry_index].name).unwrap();
|
||||
store
|
||||
.update_entry_qmdl_size(entry_index, 1000)
|
||||
.await
|
||||
.unwrap();
|
||||
let (entry_index, entry) = store
|
||||
.entry_for_name(&store.manifest.entries[entry_index].name)
|
||||
.unwrap();
|
||||
assert!(entry.last_message_time.is_some());
|
||||
assert_eq!(store.manifest.entries[entry_index].qmdl_size_bytes, 1000);
|
||||
assert_eq!(RecordingStore::read_manifest(dir.path()).await.unwrap(), store.manifest);
|
||||
assert_eq!(
|
||||
RecordingStore::read_manifest(dir.path()).await.unwrap(),
|
||||
store.manifest
|
||||
);
|
||||
|
||||
store.close_current_entry().await.unwrap();
|
||||
assert!(matches!(store.close_current_entry().await, Err(RecordingStoreError::NoCurrentEntry)));
|
||||
assert!(matches!(
|
||||
store.close_current_entry().await,
|
||||
Err(RecordingStoreError::NoCurrentEntry)
|
||||
));
|
||||
}
|
||||
|
||||
#[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 = TempDir::new("qmdl_store_test").unwrap();
|
||||
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();
|
||||
@@ -246,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,43 +1,78 @@
|
||||
use anyhow::Error;
|
||||
use async_zip::tokio::write::ZipFileWriter;
|
||||
use async_zip::Compression;
|
||||
use async_zip::ZipEntryBuilder;
|
||||
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::io::AsyncReadExt;
|
||||
use tokio::sync::mpsc::Sender;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio_util::io::ReaderStream;
|
||||
use axum::extract::State;
|
||||
use axum::http::header::{self, CONTENT_LENGTH, CONTENT_TYPE};
|
||||
use axum::http::{HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use include_dir::{include_dir, Dir};
|
||||
use log::error;
|
||||
use std::sync::Arc;
|
||||
use tokio::fs::write;
|
||||
use tokio::io::{copy, duplex, AsyncReadExt};
|
||||
use tokio::sync::mpsc::Sender;
|
||||
use tokio::sync::{oneshot, RwLock};
|
||||
use tokio_util::compat::FuturesAsyncWriteCompatExt;
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
use crate::DiagDeviceCtrlMessage;
|
||||
use crate::analysis::{AnalysisCtrlMessage, AnalysisStatus};
|
||||
use crate::config::Config;
|
||||
use crate::pcap::generate_pcap_data;
|
||||
use crate::qmdl_store::RecordingStore;
|
||||
use crate::{display, DiagDeviceCtrlMessage};
|
||||
|
||||
pub struct ServerState {
|
||||
pub config_path: String,
|
||||
pub config: Config,
|
||||
pub qmdl_store_lock: Arc<RwLock<RecordingStore>>,
|
||||
pub diag_device_ctrl_sender: Sender<DiagDeviceCtrlMessage>,
|
||||
pub readonly_mode: bool
|
||||
pub ui_update_sender: Sender<display::DisplayState>,
|
||||
pub analysis_status_lock: Arc<RwLock<AnalysisStatus>>,
|
||||
pub analysis_sender: Sender<AnalysisCtrlMessage>,
|
||||
pub daemon_restart_tx: Arc<RwLock<Option<oneshot::Sender<()>>>>,
|
||||
}
|
||||
|
||||
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 = 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).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(|err| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("error opening QMDL file: {}", err),
|
||||
)
|
||||
})?;
|
||||
let limited_qmdl_file = qmdl_file.take(entry.qmdl_size_bytes as u64);
|
||||
let 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(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();
|
||||
|
||||
@@ -56,3 +91,243 @@ pub async fn serve_static(Path(path): Path<String>) -> impl IntoResponse {
|
||||
.unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_config(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
) -> Result<Json<Config>, (StatusCode, String)> {
|
||||
Ok(Json(state.config.clone()))
|
||||
}
|
||||
|
||||
pub async fn set_config(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
Json(config): Json<Config>,
|
||||
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||
let config_str = toml::to_string_pretty(&config).map_err(|err| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("failed to serialize config as TOML: {}", err),
|
||||
)
|
||||
})?;
|
||||
|
||||
write(&state.config_path, config_str).await.map_err(|err| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("failed to write config file: {}", err),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Trigger daemon restart after writing config
|
||||
let mut restart_tx = state.daemon_restart_tx.write().await;
|
||||
if let Some(sender) = restart_tx.take() {
|
||||
sender.send(()).map_err(|_| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"couldn't send restart signal".to_string(),
|
||||
)
|
||||
})?;
|
||||
Ok((
|
||||
StatusCode::ACCEPTED,
|
||||
"wrote config and triggered restart".to_string(),
|
||||
))
|
||||
} else {
|
||||
Ok((
|
||||
StatusCode::ACCEPTED,
|
||||
"wrote config but restart already triggered".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_zip(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
Path(entry_name): Path<String>,
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
let qmdl_idx = entry_name.trim_end_matches(".zip").to_owned();
|
||||
let (entry_index, qmdl_size_bytes) = {
|
||||
let qmdl_store = state.qmdl_store_lock.read().await;
|
||||
let (entry_index, entry) = qmdl_store.entry_for_name(&qmdl_idx).ok_or((
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("couldn't find entry with name {}", qmdl_idx),
|
||||
))?;
|
||||
|
||||
if entry.qmdl_size_bytes == 0 {
|
||||
return Err((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"QMDL file is empty, try again in a bit!".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
(entry_index, entry.qmdl_size_bytes)
|
||||
};
|
||||
|
||||
let qmdl_store_lock = state.qmdl_store_lock.clone();
|
||||
|
||||
let (reader, writer) = duplex(8192);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let result: Result<(), Error> = async {
|
||||
let mut zip = ZipFileWriter::with_tokio(writer);
|
||||
|
||||
// Add QMDL file
|
||||
{
|
||||
let entry =
|
||||
ZipEntryBuilder::new(format!("{qmdl_idx}.qmdl").into(), Compression::Stored);
|
||||
// FuturesAsyncWriteCompatExt::compat_write because async-zip's entrystream does
|
||||
// not impl tokio's AsyncWrite, but only future's AsyncWrite. This can be removed
|
||||
// once https://github.com/Majored/rs-async-zip/pull/160 is released.
|
||||
let mut entry_writer = zip.write_entry_stream(entry).await?.compat_write();
|
||||
|
||||
let mut qmdl_file = {
|
||||
let qmdl_store = qmdl_store_lock.read().await;
|
||||
qmdl_store
|
||||
.open_entry_qmdl(entry_index)
|
||||
.await?
|
||||
.take(qmdl_size_bytes as u64)
|
||||
};
|
||||
|
||||
copy(&mut qmdl_file, &mut entry_writer).await?;
|
||||
entry_writer.into_inner().close().await?;
|
||||
}
|
||||
|
||||
// Add PCAP file
|
||||
{
|
||||
let entry =
|
||||
ZipEntryBuilder::new(format!("{qmdl_idx}.pcapng").into(), Compression::Stored);
|
||||
let mut entry_writer = zip.write_entry_stream(entry).await?.compat_write();
|
||||
|
||||
let qmdl_file_for_pcap = {
|
||||
let qmdl_store = qmdl_store_lock.read().await;
|
||||
qmdl_store
|
||||
.open_entry_qmdl(entry_index)
|
||||
.await?
|
||||
.take(qmdl_size_bytes as u64)
|
||||
};
|
||||
|
||||
if let Err(e) =
|
||||
generate_pcap_data(&mut entry_writer, qmdl_file_for_pcap, qmdl_size_bytes).await
|
||||
{
|
||||
// if we fail to generate the PCAP file, we should still continue and give the
|
||||
// user the QMDL.
|
||||
error!("Failed to generate PCAP: {:?}", e);
|
||||
}
|
||||
|
||||
entry_writer.into_inner().close().await?;
|
||||
}
|
||||
|
||||
zip.close().await?;
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
error!("Error generating ZIP file: {:?}", e);
|
||||
}
|
||||
});
|
||||
|
||||
let headers = [(CONTENT_TYPE, "application/zip")];
|
||||
let body = Body::from_stream(ReaderStream::new(reader));
|
||||
Ok((headers, body).into_response())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_zip::base::read::mem::ZipFileReader;
|
||||
use axum::extract::{Path, State};
|
||||
use tempfile::TempDir;
|
||||
|
||||
async fn create_test_qmdl_store() -> (TempDir, Arc<RwLock<crate::qmdl_store::RecordingStore>>) {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let store_path = temp_dir.path().to_path_buf();
|
||||
let store = crate::qmdl_store::RecordingStore::create(&store_path)
|
||||
.await
|
||||
.unwrap();
|
||||
(temp_dir, Arc::new(RwLock::new(store)))
|
||||
}
|
||||
|
||||
async fn create_test_entry_with_data(
|
||||
store_lock: &Arc<RwLock<crate::qmdl_store::RecordingStore>>,
|
||||
test_data: &[u8],
|
||||
) -> String {
|
||||
let entry_name = {
|
||||
let mut store = store_lock.write().await;
|
||||
let (mut qmdl_file, _analysis_file) = store.new_entry().await.unwrap();
|
||||
|
||||
if !test_data.is_empty() {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
qmdl_file.write_all(test_data).await.unwrap();
|
||||
qmdl_file.flush().await.unwrap();
|
||||
}
|
||||
|
||||
let current_entry = store.current_entry.unwrap();
|
||||
let entry = &store.manifest.entries[current_entry];
|
||||
let entry_name = entry.name.clone();
|
||||
|
||||
store
|
||||
.update_entry_qmdl_size(current_entry, test_data.len())
|
||||
.await
|
||||
.unwrap();
|
||||
entry_name
|
||||
};
|
||||
|
||||
let mut store = store_lock.write().await;
|
||||
store.close_current_entry().await.unwrap();
|
||||
entry_name
|
||||
}
|
||||
|
||||
fn create_test_server_state(
|
||||
store_lock: Arc<RwLock<crate::qmdl_store::RecordingStore>>,
|
||||
) -> Arc<ServerState> {
|
||||
let (tx, _rx) = tokio::sync::mpsc::channel(1);
|
||||
let (ui_tx, _ui_rx) = tokio::sync::mpsc::channel(1);
|
||||
let (analysis_tx, _analysis_rx) = tokio::sync::mpsc::channel(1);
|
||||
|
||||
let analysis_status = {
|
||||
let store = store_lock.try_read().unwrap();
|
||||
crate::analysis::AnalysisStatus::new(&*store)
|
||||
};
|
||||
|
||||
Arc::new(ServerState {
|
||||
config_path: "/tmp/test_config.toml".to_string(),
|
||||
config: Config::default(),
|
||||
qmdl_store_lock: store_lock,
|
||||
diag_device_ctrl_sender: tx,
|
||||
ui_update_sender: ui_tx,
|
||||
analysis_status_lock: Arc::new(RwLock::new(analysis_status)),
|
||||
analysis_sender: analysis_tx,
|
||||
daemon_restart_tx: Arc::new(RwLock::new(None)),
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_zip_success() {
|
||||
let (_temp_dir, store_lock) = create_test_qmdl_store().await;
|
||||
let test_qmdl_data = vec![0x7E, 0x00, 0x00, 0x00, 0x10, 0x00, 0x7E];
|
||||
let entry_name = create_test_entry_with_data(&store_lock, &test_qmdl_data).await;
|
||||
let state = create_test_server_state(store_lock);
|
||||
|
||||
let result = get_zip(State(state), Path(entry_name.clone())).await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let response = result.unwrap();
|
||||
|
||||
let headers = response.headers();
|
||||
assert_eq!(headers.get("content-type").unwrap(), "application/zip");
|
||||
|
||||
let body = response.into_body();
|
||||
let body_bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
|
||||
|
||||
let zip_reader = ZipFileReader::new(body_bytes.to_vec()).await.unwrap();
|
||||
|
||||
let filenames = zip_reader
|
||||
.file()
|
||||
.entries()
|
||||
.iter()
|
||||
.map(|entry| entry.filename().as_str().unwrap().to_owned())
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
assert_eq!(
|
||||
filenames,
|
||||
vec![format!("{entry_name}.qmdl"), format!("{entry_name}.pcapng"),]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,40 +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: #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,42 +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>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
<div>
|
||||
<h3>System stats</h3>
|
||||
<pre id="system-stats">Loading...</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Analysis Report</h3>
|
||||
<pre id="analysis-report">Loading...</pre>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,106 +0,0 @@
|
||||
async function populateDivs() {
|
||||
const systemStats = await getSystemStats();
|
||||
const systemStatsDiv = document.getElementById('system-stats');
|
||||
systemStatsDiv.innerHTML = JSON.stringify(systemStats, null, 2);
|
||||
|
||||
const analysisReport = await getAnalysisReport();
|
||||
const analysisReportDiv = document.getElementById('analysis-report');
|
||||
analysisReportDiv.innerHTML = JSON.stringify(analysisReport, null, 2);
|
||||
|
||||
const qmdlManifest = await getQmdlManifest();
|
||||
updateQmdlManifestTable(qmdlManifest);
|
||||
}
|
||||
|
||||
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);
|
||||
row.classList.add('current');
|
||||
table.appendChild(row)
|
||||
}
|
||||
for (let entry of manifest.entries) {
|
||||
table.appendChild(createEntryRow(entry));
|
||||
}
|
||||
}
|
||||
|
||||
function createEntryRow(entry) {
|
||||
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 pcap_td = document.createElement('td');
|
||||
const pcap_link = document.createElement('a');
|
||||
pcap_link.href = `/api/pcap/${entry.name}`;
|
||||
pcap_link.innerText = 'pcap';
|
||||
pcap_td.appendChild(pcap_link);
|
||||
row.appendChild(pcap_td);
|
||||
const qmdl_td = document.createElement('td');
|
||||
const qmdl_link = document.createElement('a');
|
||||
qmdl_link.href = `/api/qmdl/${entry.name}`;
|
||||
qmdl_link.innerText = 'qmdl';
|
||||
qmdl_td.appendChild(qmdl_link);
|
||||
row.appendChild(qmdl_td);
|
||||
return row;
|
||||
}
|
||||
|
||||
async function getAnalysisReport() {
|
||||
const rows = await req('GET', '/api/analysis-report');
|
||||
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.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.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>
|
||||
194
bin/web/src/lib/components/ConfigForm.svelte
Normal file
@@ -0,0 +1,194 @@
|
||||
<script lang="ts">
|
||||
import { get_config, set_config, type Config } from '../utils.svelte';
|
||||
|
||||
let config = $state<Config | null>(null);
|
||||
|
||||
let loading = $state(false);
|
||||
let saving = $state(false);
|
||||
let message = $state("");
|
||||
let messageType = $state<"success" | "error" | null>(null);
|
||||
let showConfig = $state(false);
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
loading = true;
|
||||
config = await get_config();
|
||||
message = "";
|
||||
messageType = null;
|
||||
} catch (error) {
|
||||
message = `Failed to load config: ${error}`;
|
||||
messageType = "error";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
if (!config) return;
|
||||
|
||||
try {
|
||||
saving = true;
|
||||
await set_config(config);
|
||||
message = "Config saved successfully! Rayhunter is restarting now. Reload the page in a few seconds.";
|
||||
messageType = "success";
|
||||
} catch (error) {
|
||||
message = `Failed to save config: ${error}`;
|
||||
messageType = "error";
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Load config when first shown
|
||||
$effect(() => {
|
||||
if (showConfig && !config) {
|
||||
loadConfig();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-md p-6 m-4">
|
||||
<button
|
||||
class="w-full flex justify-between items-center text-xl font-bold mb-4 text-rayhunter-dark-blue hover:text-rayhunter-blue"
|
||||
onclick={() => showConfig = !showConfig}
|
||||
>
|
||||
<span>Configuration</span>
|
||||
<svg class="w-6 h-6 transition-transform {showConfig ? 'rotate-180' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if showConfig}
|
||||
{#if loading}
|
||||
<div class="text-center py-4">Loading config...</div>
|
||||
{:else if config}
|
||||
<form class="space-y-4" onsubmit={(e) => { e.preventDefault(); saveConfig(); }}>
|
||||
<div>
|
||||
<label for="ui_level" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Device UI Level
|
||||
</label>
|
||||
<select
|
||||
id="ui_level"
|
||||
bind:value={config.ui_level}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
|
||||
>
|
||||
<option value={0}>0 - Invisible mode</option>
|
||||
<option value={1}>1 - Subtle mode (colored line)</option>
|
||||
<option value={2}>2 - Demo mode (orca gif)</option>
|
||||
<option value={3}>3 - EFF logo</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key_input_mode" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Device Input Mode
|
||||
</label>
|
||||
<select
|
||||
id="key_input_mode"
|
||||
bind:value={config.key_input_mode}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
|
||||
>
|
||||
<option value={0}>0 - Disable button control</option>
|
||||
<option value={1}>1 - Double-tap power button to start/stop recording</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="colorblind_mode"
|
||||
type="checkbox"
|
||||
bind:checked={config.colorblind_mode}
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
|
||||
/>
|
||||
<label for="colorblind_mode" class="ml-2 block text-sm text-gray-700">
|
||||
Colorblind Mode
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t pt-4 mt-6">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">Analyzer Heuristic Settings</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="imsi_requested"
|
||||
type="checkbox"
|
||||
bind:checked={config.analyzers.imsi_requested}
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
|
||||
/>
|
||||
<label for="imsi_requested" class="ml-2 block text-sm text-gray-700">
|
||||
IMSI Requested Heuristic
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="connection_redirect_2g_downgrade"
|
||||
type="checkbox"
|
||||
bind:checked={config.analyzers.connection_redirect_2g_downgrade}
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
|
||||
/>
|
||||
<label for="connection_redirect_2g_downgrade" class="ml-2 block text-sm text-gray-700">
|
||||
Connection Redirect 2G Downgrade Heuristic
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="lte_sib6_and_7_downgrade"
|
||||
type="checkbox"
|
||||
bind:checked={config.analyzers.lte_sib6_and_7_downgrade}
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
|
||||
/>
|
||||
<label for="lte_sib6_and_7_downgrade" class="ml-2 block text-sm text-gray-700">
|
||||
LTE SIB6 and SIB7 Downgrade Heuristic
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="null_cipher"
|
||||
type="checkbox"
|
||||
bind:checked={config.analyzers.null_cipher}
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
|
||||
/>
|
||||
<label for="null_cipher" class="ml-2 block text-sm text-gray-700">
|
||||
Null Cipher Heuristic
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
class="bg-blue-500 hover:bg-blue-700 disabled:opacity-50 text-white font-bold py-2 px-4 rounded-md flex flex-row gap-1 items-center"
|
||||
>
|
||||
{#if saving}
|
||||
<div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
Saving...
|
||||
{:else}
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
Apply and restart
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
{#if message}
|
||||
<div class="mt-4 p-3 rounded {messageType === 'error' ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700'}">
|
||||
{message}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="text-center py-4 text-red-600">
|
||||
Failed to load configuration. Please try reloading the page.
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
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>
|
||||
75
bin/web/src/lib/components/ManifestCard.svelte
Normal file
@@ -0,0 +1,75 @@
|
||||
<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: {entry.last_message_time && date_formatter.format(entry.last_message_time) || "N/A"}</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 />
|
||||
<DownloadLink url={entry.get_zip_url()} text="zip" 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>
|
||||
38
bin/web/src/lib/components/ManifestTable.svelte
Normal file
@@ -0,0 +1,38 @@
|
||||
<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">ZIP</th>
|
||||
<th class='p-2' scope="col">Analysis</th>
|
||||
<th class='p-2' scope="col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each entries as entry, i}
|
||||
<TableRow {entry} current={false} {i} />
|
||||
{/each}
|
||||
</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} {server_is_recording} />
|
||||
{/each}
|
||||
</div>
|
||||
56
bin/web/src/lib/components/ManifestTableRow.svelte
Normal file
@@ -0,0 +1,56 @@
|
||||
<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">{entry.last_message_time && date_formatter.format(entry.last_message_time) || "N/A"}</td>
|
||||
<td class="p-2">{entry.get_readable_qmdl_size()}</td>
|
||||
<td class="p-2"><DownloadLink url={entry.get_pcap_url()} text="pcap" /></td>
|
||||
<td class="p-2"><DownloadLink url={entry.get_qmdl_url()} text="qmdl" /></td>
|
||||
<td class="p-2"><DownloadLink url={entry.get_zip_url()} text="zip" /></td>
|
||||
<td class="p-2"><AnalysisStatus onclick={toggle_analysis_visibility} entry={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="9">
|
||||
<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.
|
||||
107
bin/web/src/lib/manifest.svelte.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
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) {
|
||||
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}.pcapng`;
|
||||
}
|
||||
|
||||
get_qmdl_url(): string {
|
||||
return `/api/qmdl/${this.name}.qmdl`;
|
||||
}
|
||||
|
||||
get_zip_url(): string {
|
||||
return `/api/zip/${this.name}.zip`;
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
56
bin/web/src/lib/utils.svelte.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Manifest } from "./manifest.svelte";
|
||||
import type { SystemStats } from "./systemStats";
|
||||
|
||||
export interface AnalyzerConfig {
|
||||
imsi_requested: boolean;
|
||||
connection_redirect_2g_downgrade: boolean;
|
||||
lte_sib6_and_7_downgrade: boolean;
|
||||
null_cipher: boolean;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
ui_level: number;
|
||||
colorblind_mode: boolean;
|
||||
key_input_mode: number;
|
||||
analyzers: AnalyzerConfig;
|
||||
}
|
||||
|
||||
export async function req(method: string, url: string): Promise<string> {
|
||||
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'));
|
||||
}
|
||||
|
||||
export async function get_config(): Promise<Config> {
|
||||
return JSON.parse(await req('GET', '/api/config'));
|
||||
}
|
||||
|
||||
export async function set_config(config: Config): Promise<void> {
|
||||
const response = await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(error);
|
||||
}
|
||||
}
|
||||
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()}
|
||||
86
bin/web/src/routes/+page.svelte
Normal file
@@ -0,0 +1,86 @@
|
||||
<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";
|
||||
import ConfigForm from "$lib/components/ConfigForm.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/>
|
||||
<ConfigForm />
|
||||
{: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 |
20
bin/web/svelte.config.js
Normal file
@@ -0,0 +1,20 @@
|
||||
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
|
||||
}),
|
||||
version: {
|
||||
// Use a deterministic version string for reproducible builds.
|
||||
// Without this option, SvelteKit will use a timestamp.
|
||||
name: process.env.GITHUB_SHA || 'dev'
|
||||
}
|
||||
}
|
||||
};
|
||||
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}']
|
||||
}
|
||||
});
|
||||
8
book.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[book]
|
||||
authors = ["The Rayhunter Team"]
|
||||
language = "en"
|
||||
src = "doc"
|
||||
title = "Rayhunter - An IMSI Catcher Catcher"
|
||||
|
||||
[output.html]
|
||||
edit-url-template = "https://github.com/efforg/rayhunter/edit/main/{path}"
|
||||
31
dist/config.toml.example
vendored
@@ -1,10 +1,31 @@
|
||||
# cat config.toml
|
||||
qmdl_store_path = "/data/rayhunter/qmdl"
|
||||
port = 8080
|
||||
readonly_mode = false
|
||||
# UI Levels:
|
||||
# 0 = invisible mode, no indicator that rayhunter is running
|
||||
# 1 = Subtle mode, display a green line at the top of the screen when rayhunter is running
|
||||
# 2 = Demo Mode, display a fun orca gif
|
||||
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 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 = 0
|
||||
|
||||
# Analyzer Configuration
|
||||
# Enable/disable specific IMSI catcher detection heuristics
|
||||
# See https://github.com/EFForg/rayhunter/blob/main/doc/heuristics.md for details
|
||||
[analyzers]
|
||||
imsi_requested = true
|
||||
connection_redirect_2g_downgrade = true
|
||||
lte_sib6_and_7_downgrade = true
|
||||
null_cipher = true
|
||||
|
||||
97
dist/install-common.sh
vendored
@@ -1,97 +0,0 @@
|
||||
#!/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
|
||||
check_adb
|
||||
force_debug_mode
|
||||
setup_rootshell
|
||||
setup_rayhunter
|
||||
test_rayhunter
|
||||
}
|
||||
|
||||
check_adb() {
|
||||
if ! command -v adb &> /dev/null
|
||||
then
|
||||
echo "adb not found, please ensure it's installed or check the README.md"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
force_debug_mode() {
|
||||
# Force a switch into the debug mode to enable ADB
|
||||
"$SERIAL_PATH" AT
|
||||
echo -n "adb enabled, waiting for reboot"
|
||||
wait_for_adb_shell
|
||||
echo "it's alive!"
|
||||
}
|
||||
|
||||
wait_for_adb_shell() {
|
||||
until adb shell true 2> /dev/null
|
||||
do
|
||||
echo -n .
|
||||
sleep 1
|
||||
done
|
||||
echo
|
||||
}
|
||||
|
||||
setup_rootshell() {
|
||||
_adb_push rootshell /tmp/
|
||||
"$SERIAL_PATH" "AT+SYSCMD=cp /tmp/rootshell /bin/rootshell"
|
||||
sleep 1
|
||||
"$SERIAL_PATH" "AT+SYSCMD=chown root /bin/rootshell"
|
||||
sleep 1
|
||||
"$SERIAL_PATH" "AT+SYSCMD=chmod 4755 /bin/rootshell"
|
||||
echo "we have root!"
|
||||
adb shell /bin/rootshell -c id
|
||||
}
|
||||
|
||||
_adb_push() {
|
||||
adb push "$(dirname "$0")/$1" "$2"
|
||||
}
|
||||
|
||||
setup_rayhunter() {
|
||||
adb shell '/bin/rootshell -c "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
|
||||
adb shell '/bin/rootshell -c "cp /tmp/rayhunter_daemon /etc/init.d/rayhunter_daemon"'
|
||||
adb shell '/bin/rootshell -c "cp /tmp/misc-daemon /etc/init.d/misc-daemon"'
|
||||
adb shell '/bin/rootshell -c "chmod 755 /etc/init.d/rayhunter_daemon"'
|
||||
adb shell '/bin/rootshell -c "chmod 755 /etc/init.d/misc-daemon"'
|
||||
echo -n "rebooting, this may take a sec..."
|
||||
adb shell '/bin/rootshell -c reboot'
|
||||
|
||||
# first wait for shutdown (it can take ~10s)
|
||||
until ! adb shell true 2> /dev/null
|
||||
do
|
||||
echo -n '.'
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# now wait for boot to finish
|
||||
wait_for_adb_shell
|
||||
|
||||
echo "rebooted successfully!"
|
||||
}
|
||||
|
||||
test_rayhunter() {
|
||||
URL="http://localhost:8080"
|
||||
adb forward tcp:8080 tcp:8080
|
||||
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
|
||||
echo "success! you can access rayhunter at $URL"
|
||||
return
|
||||
fi
|
||||
sleep 1
|
||||
echo -n "."
|
||||
done
|
||||
echo "timeout reached! failed to reach rayhunter url $URL, something went wrong :("
|
||||
}
|
||||
6
dist/install-linux.sh
vendored
@@ -1,6 +0,0 @@
|
||||
#!/bin/env bash
|
||||
|
||||
set -e
|
||||
export SERIAL_PATH="./serial-ubuntu-latest/serial"
|
||||
. "$(dirname "$0")"/install-common.sh
|
||||
install
|
||||
6
dist/install-mac.sh
vendored
@@ -1,6 +0,0 @@
|
||||
#!/bin/env bash
|
||||
|
||||
set -e
|
||||
export SERIAL_PATH="./serial-mac-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 |
19
doc/SUMMARY.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# 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)
|
||||
- [Configuration](./configuration.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)
|
||||
- [TP-Link M7310](./tplink-m7310.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
|
||||
5
doc/configuration.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Configuration
|
||||
|
||||
Rayhunter can be configured by editing `/data/rayhunter/config.toml` on the device. You can obtain a shell on [orbic](./orbic.md#obtaining-a-shell) and [tplink](./tplink-m7350.md#obtaining-a-shell) and edit the file manually. In future versions the web UI will allow you to edit the config as well.
|
||||
|
||||
View the [default configuration file on GitHub](https://github.com/EFForg/rayhunter/blob/main/dist/config.toml.example).
|
||||
39
doc/faq.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# 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.
|
||||
|
||||
|
||||
### How do I re-enable USB tethering after installing Rayhunter?
|
||||
|
||||
Make sure USB tethering is also enabled in the Orbic's UI, and then run the following commands:
|
||||
|
||||
```sh
|
||||
installer util shell "echo 9 > /usrdata/mode.cfg"
|
||||
installer util shell reboot
|
||||
```
|
||||
|
||||
To disable tethering again:
|
||||
|
||||
```sh
|
||||
installer util shell "echo 3 > /usrdata/mode.cfg"
|
||||
installer util shell reboot
|
||||
```
|
||||
|
||||
See `/data/usb/boot_hsusb_composition` for a list of USB modes and Android USB gadget settings.
|
||||
22
doc/heuristics.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Heuristics
|
||||
|
||||
Rayhunter includes several analyzers to detect potential IMSI catcher activity. These can be enabled and disabled in your [config.toml](./configuration.md) file.
|
||||
|
||||
## Available Analyzers
|
||||
|
||||
- **IMSI Requested**: Tests whether the eNodeB sends an IMSI Identity Request NAS message. This
|
||||
can sometimes happen under normal circumstances when the network doesn't already have a TMSI
|
||||
(Temporary Mobile Subscriber ID or GUTI in 5G terminology) for your device. This most often
|
||||
happens when you first turn the device on, especially after it has been off for a long time or
|
||||
if you are in an area where ther is absolutely no connection to your service provider. This can
|
||||
also happen if you leave your device on while on an airplane and it suddenly connects to a new
|
||||
tower after being disconnected for a long time.
|
||||
However, if you get this warning at a time when you have been steadily connected to towers and the device has been on for a while it can be treated as suspcious.
|
||||
- **Connection Release/Redirected Carrier 2G Downgrade**: Tests if a cell
|
||||
releases our connection and redirects us to a 2G cell. This heuristic only
|
||||
makes sense in the US or other countries where there are no more operating 2G base stations.
|
||||
Users in contries where 2G is still in service (such as most of EU) may want to disable it.
|
||||
See https://en.wikipedia.org/wiki/2G#Past_2G_networks for information about your country.
|
||||
- **LTE SIB6/7 Downgrade**: Tests for LTE cells broadcasting a SIB type 6 and 7
|
||||
which include 2G/3G frequencies with higher priorities
|
||||
- **Null Cipher**: Tests whether the cell suggests using a null cipher (EEA0).
|
||||
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](./using-rayhunter.md#the-web-ui). You should also see a green line flash along the top of top the display on the device.
|
||||