mirror of
https://github.com/EFForg/rayhunter.git
synced 2026-05-30 13:19:26 -07:00
Compare commits
330 Commits
v0.3.2
...
no-adb-aut
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df55c04e85 | ||
|
|
7475cd5cd9 | ||
|
|
cef94ba6b0 | ||
|
|
d7c973ea95 | ||
|
|
64d657efd6 | ||
|
|
16447ed8bf | ||
|
|
663d0abb57 | ||
|
|
f49d11f034 | ||
|
|
56dcfdb47c | ||
|
|
a46ede37b6 | ||
|
|
69dc528f34 | ||
|
|
29ce6729ee | ||
|
|
5919a19aba | ||
|
|
35ca590e46 | ||
|
|
56122f6559 | ||
|
|
bbab29ae0b | ||
|
|
2a620fd1fb | ||
|
|
515bb40a76 | ||
|
|
a5ec1c9505 | ||
|
|
806bd62a0e | ||
|
|
6ceced2d31 | ||
|
|
856374c05a | ||
|
|
983867c2a6 | ||
|
|
145d0a295a | ||
|
|
c021b9150d | ||
|
|
ce916dcd10 | ||
|
|
898bdbb6cd | ||
|
|
375789aad9 | ||
|
|
85f7b2cc81 | ||
|
|
781d11ed72 | ||
|
|
6927da49b4 | ||
|
|
479505f738 | ||
|
|
468b07faf0 | ||
|
|
493fdfa227 | ||
|
|
ffdad4aed8 | ||
|
|
33e4fbc544 | ||
|
|
8c510b43c9 | ||
|
|
46850e2739 | ||
|
|
53e3b8ee34 | ||
|
|
0fc51d79f4 | ||
|
|
ad4e971e77 | ||
|
|
c5a79e545d | ||
|
|
9d92ab3c01 | ||
|
|
cf254b66ff | ||
|
|
cddc590c77 | ||
|
|
9d736f5bf0 | ||
|
|
e5df43d7f5 | ||
|
|
a8667cc3a0 | ||
|
|
3239daa011 | ||
|
|
651511cc63 | ||
|
|
211066ec7b | ||
|
|
16ec9e28df | ||
|
|
4462f02c10 | ||
|
|
5bd2d9a58e | ||
|
|
603d65a3bd | ||
|
|
c0a9cf62df | ||
|
|
0a20e659be | ||
|
|
ce599dc432 | ||
|
|
85b50bc301 | ||
|
|
5249714717 | ||
|
|
67974264f9 | ||
|
|
f562d33be3 | ||
|
|
0531aa0e3a | ||
|
|
dd78f5007d | ||
|
|
1c08708bc4 | ||
|
|
0f53da58bc | ||
|
|
01010df4ec | ||
|
|
481f02f81f | ||
|
|
8c67a92b07 | ||
|
|
31bd60dea1 | ||
|
|
13877f7209 | ||
|
|
f4522dbe3d | ||
|
|
30bb18016e | ||
|
|
c6aa53acd2 | ||
|
|
c6882ed173 | ||
|
|
5c03f6ea03 | ||
|
|
5184c6138d | ||
|
|
c893f8e2a9 | ||
|
|
2e6343c343 | ||
|
|
da4a86be13 | ||
|
|
55794cbdd5 | ||
|
|
e36b490d15 | ||
|
|
574e897610 | ||
|
|
1f19bc880f | ||
|
|
8dc6206683 | ||
|
|
7184ccd5c1 | ||
|
|
cb22e179d6 | ||
|
|
a3db5029ad | ||
|
|
9f661ab398 | ||
|
|
412ad3d8bf | ||
|
|
4d2d49326a | ||
|
|
c26ad29ffb | ||
|
|
f57fc611c2 | ||
|
|
38a408757a | ||
|
|
0540504eea | ||
|
|
28a0c06017 | ||
|
|
6141087f9d | ||
|
|
7a053a4f89 | ||
|
|
6473c05e3e | ||
|
|
c697773244 | ||
|
|
fe6afac817 | ||
|
|
8e708f145e | ||
|
|
03c00a1f19 | ||
|
|
64842c7140 | ||
|
|
e108c21fc2 | ||
|
|
49a2108214 | ||
|
|
53a6cbe95a | ||
|
|
398997af67 | ||
|
|
6b109a9d76 | ||
|
|
d9688b1796 | ||
|
|
7466c1c669 | ||
|
|
6a51050921 | ||
|
|
0935cf8239 | ||
|
|
d25e9588e2 | ||
|
|
a8ff95a07b | ||
|
|
ac86277903 | ||
|
|
8e9abc718a | ||
|
|
d92fb16c57 | ||
|
|
f8824ce7e7 | ||
|
|
9694aa826b | ||
|
|
b859dde0c8 | ||
|
|
5b6a73bc44 | ||
|
|
8cbdbf5ebe | ||
|
|
ccce63e90c | ||
|
|
68b13ea09e | ||
|
|
672d825bdb | ||
|
|
fd216ecb72 | ||
|
|
07d43b5924 | ||
|
|
bd3e439a1d | ||
|
|
5491c3f3a0 | ||
|
|
fa14e4ecfc | ||
|
|
8583064e46 | ||
|
|
d3bd8d9dfc | ||
|
|
b16a351727 | ||
|
|
cd781fe8d8 | ||
|
|
df00e00076 | ||
|
|
1a810cfb33 | ||
|
|
b16b1af65e | ||
|
|
a346449ec5 | ||
|
|
464740a1a7 | ||
|
|
e07b0b05e7 | ||
|
|
578bc0d234 | ||
|
|
751d504440 | ||
|
|
29c944af45 | ||
|
|
e239653a44 | ||
|
|
841bc7b015 | ||
|
|
22d927aa25 | ||
|
|
5b59efa4c8 | ||
|
|
f273d28728 | ||
|
|
f1e283b52c | ||
|
|
1011c4b123 | ||
|
|
5db24e4b21 | ||
|
|
a72e4b2234 | ||
|
|
ca0151f656 | ||
|
|
56930db130 | ||
|
|
f018b8f662 | ||
|
|
7e0f12f1c5 | ||
|
|
e32a6f5b2e | ||
|
|
58618f3412 | ||
|
|
003a8b280b | ||
|
|
27bf20fbf4 | ||
|
|
b7636386fc | ||
|
|
f23cc07652 | ||
|
|
f9b621bde9 | ||
|
|
a4cb9454bd | ||
|
|
fbac464b46 | ||
|
|
b923d9d5a6 | ||
|
|
790c0963cd | ||
|
|
32106ac0f4 | ||
|
|
1ce4d99c59 | ||
|
|
b055ddc670 | ||
|
|
09d4328dc2 | ||
|
|
1a4deb7524 | ||
|
|
0585e0f996 | ||
|
|
c783831e78 | ||
|
|
3ddbaa07ca | ||
|
|
83f246e9af | ||
|
|
0d96b4c103 | ||
|
|
7cd8835cab | ||
|
|
e81df18315 | ||
|
|
0915103ede | ||
|
|
da18a1f9da | ||
|
|
5bb3dc9db5 | ||
|
|
c2c6004f4e | ||
|
|
e320874854 | ||
|
|
300215206c | ||
|
|
5e328b889b | ||
|
|
97cbe62f42 | ||
|
|
27408dd64a | ||
|
|
e5c0e13d32 | ||
|
|
41133ba793 | ||
|
|
0be2b02349 | ||
|
|
81eb3eac57 | ||
|
|
3247d35b7e | ||
|
|
355242fa71 | ||
|
|
72d6c65f29 | ||
|
|
5e66c26e70 | ||
|
|
b0d8307a14 | ||
|
|
cf0875f2e3 | ||
|
|
1c51e5ed6f | ||
|
|
3a393fc29f | ||
|
|
b97421d220 | ||
|
|
1bf386d5b7 | ||
|
|
8de4dcfd18 | ||
|
|
c0b1d4608a | ||
|
|
ee8bf0107a | ||
|
|
664ffc8c75 | ||
|
|
d03debe67c | ||
|
|
60922afc87 | ||
|
|
932fef32b9 | ||
|
|
e259417f35 | ||
|
|
3889c89b5a | ||
|
|
bd074066c5 | ||
|
|
8b44f604ea | ||
|
|
ef7b8129ef | ||
|
|
c3fd724ac1 | ||
|
|
28ead37111 | ||
|
|
6efe83b36d | ||
|
|
4d0427fe68 | ||
|
|
1ee35dad71 | ||
|
|
5d2a5a2577 | ||
|
|
a4f4e12a57 | ||
|
|
55178e60fd | ||
|
|
5019f2a9d1 | ||
|
|
f55d9128d4 | ||
|
|
25978a4da4 | ||
|
|
4ad79707bb | ||
|
|
5f45ae31d8 | ||
|
|
ed3072eb8e | ||
|
|
94289dcad5 | ||
|
|
84534bbb2c | ||
|
|
1d50440c85 | ||
|
|
2c05f3d94e | ||
|
|
2b86691e57 | ||
|
|
0a15ca1b1a | ||
|
|
eeef42f4cb | ||
|
|
04cf0ab73a | ||
|
|
23a0f72c2f | ||
|
|
efae6203a9 | ||
|
|
2e4de4a2df | ||
|
|
deeab1f1b0 | ||
|
|
83dba77cba | ||
|
|
542aff4fdf | ||
|
|
aac0c34eaa | ||
|
|
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 |
@@ -15,6 +15,10 @@ rustflags = ["-C", "target-feature=+crt-static"]
|
|||||||
linker = "rust-lld"
|
linker = "rust-lld"
|
||||||
rustflags = ["-C", "target-feature=+crt-static"]
|
rustflags = ["-C", "target-feature=+crt-static"]
|
||||||
|
|
||||||
|
[target.armv7-unknown-linux-musleabi]
|
||||||
|
linker = "rust-lld"
|
||||||
|
rustflags = ["-C", "target-feature=+crt-static"]
|
||||||
|
|
||||||
# Disable rust-lld for x86 macOS because the linker crashers when compiling
|
# Disable rust-lld for x86 macOS because the linker crashers when compiling
|
||||||
# the installer in release mode with debug info on.
|
# the installer in release mode with debug info on.
|
||||||
# [target.x86_64-apple-darwin]
|
# [target.x86_64-apple-darwin]
|
||||||
@@ -25,17 +29,22 @@ rustflags = ["-C", "target-feature=+crt-static"]
|
|||||||
linker = "rust-lld"
|
linker = "rust-lld"
|
||||||
rustflags = ["-C", "target-feature=+crt-static"]
|
rustflags = ["-C", "target-feature=+crt-static"]
|
||||||
|
|
||||||
# keep line numbers in stack traces for non-firmware binaries
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
|
# keep line numbers in stack traces for non-firmware binaries
|
||||||
debug = "limited"
|
debug = "limited"
|
||||||
|
lto = "fat"
|
||||||
|
opt-level = "z"
|
||||||
|
strip = "debuginfo"
|
||||||
|
|
||||||
|
[profile.firmware-devel]
|
||||||
|
inherits = "release"
|
||||||
|
opt-level = "s"
|
||||||
|
lto = false
|
||||||
|
|
||||||
# optimizations to reduce the binary size of firmware binaries
|
# optimizations to reduce the binary size of firmware binaries
|
||||||
[profile.firmware]
|
[profile.firmware]
|
||||||
inherits = "release"
|
inherits = "release"
|
||||||
strip = true
|
strip = true
|
||||||
opt-level = "z"
|
|
||||||
lto = true
|
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
panic = "abort"
|
panic = "abort"
|
||||||
debug = false
|
debug = false
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
c5bbaabe15d4ccfee97b9997a13569fbfea13c45
|
9fe75ac961c57e508bf7488ce51d596750fa8d37
|
||||||
|
76ffdf6bada515c9a5f63a600e6f1502288c147a
|
||||||
|
|||||||
9
.gitattributes
vendored
Normal file
9
.gitattributes
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Files that are distributed onto the Rayhunter device always have to have
|
||||||
|
# Unix-style line endings, even if the installer is built on Windows with
|
||||||
|
# autocrlf enabled.
|
||||||
|
# Using CRLF for the init scripts will make them fail to execute on TP-Link.
|
||||||
|
# See https://github.com/EFForg/rayhunter/issues/489
|
||||||
|
|
||||||
|
dist/config.toml.in eol=lf
|
||||||
|
dist/scripts/misc-daemon eol=lf
|
||||||
|
dist/scripts/rayhunter_daemon eol=lf
|
||||||
62
.github/ISSUE_TEMPLATE/bug.yaml
vendored
62
.github/ISSUE_TEMPLATE/bug.yaml
vendored
@@ -1,59 +1,19 @@
|
|||||||
name: Bug Report
|
name: Bug Report
|
||||||
description: File a bug report.
|
description: File a bug report.
|
||||||
title: "[Bug]: "
|
labels: ["bug"]
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
Thanks for taking the time to fill out this bug report!
|
|
||||||
- type: input
|
|
||||||
attributes:
|
|
||||||
label: Rayhunter Version
|
|
||||||
description: |
|
|
||||||
Which version did you install?
|
|
||||||
placeholder: "v0.2.6"
|
|
||||||
- type: input
|
|
||||||
attributes:
|
|
||||||
label: Capture Date
|
|
||||||
description: |
|
|
||||||
YYYY-MM-DD
|
|
||||||
placeholder: "2025-05-01"
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: input
|
|
||||||
attributes:
|
|
||||||
label: Capture Location
|
|
||||||
description: |
|
|
||||||
(If comfortable disclosing) What region or country were you in?
|
|
||||||
placeholder: Washington State
|
|
||||||
- type: input
|
|
||||||
attributes:
|
|
||||||
label: Device and Model
|
|
||||||
description: |
|
|
||||||
Device you installed Rayhunter on to.
|
|
||||||
placeholder: Orbic RC400L
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: what-happened
|
|
||||||
attributes:
|
attributes:
|
||||||
label: What happened?
|
label: Bug Report Details
|
||||||
description: |
|
description: |
|
||||||
What steps did you take to get to your issue?
|
Please provide the following information, if applicable:
|
||||||
placeholder: "Tell us what you see!"
|
placeholder: |
|
||||||
|
• **Rayhunter Version**: (e.g., v0.2.6)
|
||||||
|
• **Capture Date**: (YYYY-MM-DD, e.g., 2025-05-01)
|
||||||
|
• **Capture Location**: (If comfortable disclosing, what region or country were you in? e.g., Washington State)
|
||||||
|
• **Device and Model**: (Device you installed Rayhunter on, e.g., Orbic RC400L)
|
||||||
|
• **What happened?**: (What steps did you take to get to your issue? Tell us what you see!)
|
||||||
|
• **Expected behavior**: (Rayhunter's behavior differed from what I expected because...)
|
||||||
|
• **Relevant log output**: (Rayhunter data captures - QMDL and PCAP logs - or error codes)
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
|
||||||
id: expected
|
|
||||||
attributes:
|
|
||||||
label: Expected behavior
|
|
||||||
description: Rayhunter's behavior differed from what I expected because.
|
|
||||||
placeholder: "What was expected?"
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: logs
|
|
||||||
attributes:
|
|
||||||
label: Relevant log output
|
|
||||||
description: Rayhunter data captures (QMDL and PCAP logs) or error codes
|
|
||||||
render: shell
|
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
blank_issues_enabled: false
|
blank_issues_enabled: true
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: Rayhunter Mattermost
|
- name: Rayhunter Mattermost
|
||||||
url: https://opensource.eff.org/signup_user_complete/?id=6iqur37ucfrctfswrs14iscobw&md=link&sbr=su
|
url: https://opensource.eff.org/signup_user_complete/?id=6iqur37ucfrctfswrs14iscobw&md=link&sbr=su
|
||||||
|
|||||||
1
.github/ISSUE_TEMPLATE/feature.yaml
vendored
1
.github/ISSUE_TEMPLATE/feature.yaml
vendored
@@ -1,6 +1,5 @@
|
|||||||
name: Feature Request
|
name: Feature Request
|
||||||
description: Suggest a new feature or improvement to Rayhunter
|
description: Suggest a new feature or improvement to Rayhunter
|
||||||
title: "[Feature Request]: "
|
|
||||||
labels: ["enhancement"]
|
labels: ["enhancement"]
|
||||||
body:
|
body:
|
||||||
- type: textarea
|
- type: textarea
|
||||||
|
|||||||
47
.github/ISSUE_TEMPLATE/installer-bug.yaml
vendored
Normal file
47
.github/ISSUE_TEMPLATE/installer-bug.yaml
vendored
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
name: Installer Issue
|
||||||
|
description: File an bug related to an installer issue.
|
||||||
|
labels: ["bug", "installer"]
|
||||||
|
body:
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: Rayhunter Version
|
||||||
|
placeholder: 'v0.5.0'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
attributes:
|
||||||
|
label: Device
|
||||||
|
description: |
|
||||||
|
What device are you trying to install Rayhunter on?
|
||||||
|
options:
|
||||||
|
- Orbic RC400L
|
||||||
|
- Tplink M7350
|
||||||
|
- Tplink M7310
|
||||||
|
- Tmobile TMOHS1
|
||||||
|
- Wingtech CT2MHS0
|
||||||
|
- Pinephone
|
||||||
|
- Other / I'm not sure
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
attributes:
|
||||||
|
label: Installer OS
|
||||||
|
description: What operating system are running the installer from
|
||||||
|
multiple: false
|
||||||
|
options:
|
||||||
|
- Linux
|
||||||
|
- macOS
|
||||||
|
- Windows
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Describe the Issue
|
||||||
|
description: |
|
||||||
|
Please describe the issue you're having installing Rayhunter.
|
||||||
|
Include the logs outputed by the installer program. If the installer
|
||||||
|
is crashing, please try running the installer with `RUST_BACKTRACE=1`
|
||||||
|
environment variable set so we can see exactly where the installer is
|
||||||
|
crashing.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
162
.github/workflows/build-release.yml
vendored
162
.github/workflows/build-release.yml
vendored
@@ -1,162 +0,0 @@
|
|||||||
name: Build Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main, "release-*"]
|
|
||||||
pull_request:
|
|
||||||
branches: ["main"]
|
|
||||||
|
|
||||||
env:
|
|
||||||
CARGO_TERM_COLOR: always
|
|
||||||
FILE_ROOTSHELL: ../../rootshell/rootshell
|
|
||||||
FILE_RAYHUNTER_DAEMON_ORBIC: ../../rayhunter-daemon-orbic/rayhunter-daemon
|
|
||||||
FILE_RAYHUNTER_DAEMON_TPLINK: ../../rayhunter-daemon-tplink/rayhunter-daemon
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build_rayhunter_check:
|
|
||||||
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
|
|
||||||
- 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:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
|
||||||
with:
|
|
||||||
targets: armv7-unknown-linux-musleabihf
|
|
||||||
- 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:
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
device:
|
|
||||||
- name: tplink
|
|
||||||
- name: orbic
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
|
||||||
with:
|
|
||||||
targets: armv7-unknown-linux-musleabihf
|
|
||||||
- name: Build rayhunter-daemon (arm32)
|
|
||||||
run: |
|
|
||||||
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:
|
|
||||||
needs:
|
|
||||||
- build_rayhunter
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
platform:
|
|
||||||
- name: ubuntu-24
|
|
||||||
os: ubuntu-latest
|
|
||||||
target: x86_64-unknown-linux-musl
|
|
||||||
- name: ubuntu-24-aarch64
|
|
||||||
os: ubuntu-24.04-arm
|
|
||||||
target: aarch64-unknown-linux-musl
|
|
||||||
- name: macos-arm
|
|
||||||
os: macos-latest
|
|
||||||
target: aarch64-apple-darwin
|
|
||||||
- name: macos-intel
|
|
||||||
os: macos-13
|
|
||||||
target: x86_64-apple-darwin
|
|
||||||
- name: windows-x86_64
|
|
||||||
os: windows-latest
|
|
||||||
target: x86_64-pc-windows-gnu
|
|
||||||
runs-on: ${{ matrix.platform.os }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/download-artifact@v4
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
|
||||||
with:
|
|
||||||
targets: ${{ matrix.platform.target }}
|
|
||||||
- run: cargo build --bin installer --release --target ${{ matrix.platform.target }}
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: installer-${{ matrix.platform.name }}
|
|
||||||
path: target/${{ matrix.platform.target }}/release/installer${{ matrix.platform.os == 'windows-latest' && '.exe' || '' }}
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
build_release_zip:
|
|
||||||
needs:
|
|
||||||
- build_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
|
|
||||||
54
.github/workflows/check-and-test.yml
vendored
54
.github/workflows/check-and-test.yml
vendored
@@ -1,54 +0,0 @@
|
|||||||
name: Check and Test
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ "main" ]
|
|
||||||
pull_request:
|
|
||||||
branches: [ "main" ]
|
|
||||||
|
|
||||||
env:
|
|
||||||
CARGO_TERM_COLOR: always
|
|
||||||
NO_FIRMWARE_BIN: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check_and_test:
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
device:
|
|
||||||
- name: tplink
|
|
||||||
- name: orbic
|
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- name: Check
|
|
||||||
run: |
|
|
||||||
pushd bin/web
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
popd
|
|
||||||
cargo check --verbose --no-default-features --features=${{ matrix.device.name }}
|
|
||||||
- name: Run tests
|
|
||||||
run: |
|
|
||||||
pushd bin/web
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
popd
|
|
||||||
cargo test --verbose --no-default-features --features=${{ matrix.device.name }}
|
|
||||||
- name: Run clippy
|
|
||||||
run: cargo clippy --verbose --no-default-features --features=${{ matrix.device.name }}
|
|
||||||
|
|
||||||
windows_installer_check_and_test:
|
|
||||||
runs-on: windows-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- name: cargo check
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
cd installer
|
|
||||||
cargo check --verbose
|
|
||||||
- name: cargo test
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
cd installer
|
|
||||||
cargo test --verbose --no-default-features --features=${{ matrix.device.name }}
|
|
||||||
358
.github/workflows/main.yml
vendored
Normal file
358
.github/workflows/main.yml
vendored
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
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: ../../rayhunter-daemon/rayhunter-daemon
|
||||||
|
RUSTFLAGS: "-Dwarnings"
|
||||||
|
|
||||||
|
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 }}
|
||||||
|
web_changed: ${{ steps.files_changed.outputs.web_count }}
|
||||||
|
docs_changed: ${{ steps.files_changed.outputs.docs_count }}
|
||||||
|
installer_changed: ${{ steps.files_changed.outputs.installer_count }}
|
||||||
|
rootshell_changed: ${{ steps.files_changed.outputs.rootshell_count }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- 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/ -e ^.cargo
|
||||||
|
then
|
||||||
|
echo "building everything"
|
||||||
|
echo code_count=forced >> "$GITHUB_OUTPUT"
|
||||||
|
echo daemon_count=forced >> "$GITHUB_OUTPUT"
|
||||||
|
echo web_count=forced >> "$GITHUB_OUTPUT"
|
||||||
|
echo docs_count=forced >> "$GITHUB_OUTPUT"
|
||||||
|
echo installer_count=forced >> "$GITHUB_OUTPUT"
|
||||||
|
echo rootshell_count=forced >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "code_count=$(git diff --name-only $lcommit...HEAD | grep -e ^daemon -e ^installer -e ^check -e ^lib -e ^rootshell -e ^telcom-parser | wc -l)" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "daemon_count=$(git diff --name-only $lcommit...HEAD | grep -e ^daemon -e ^lib -e ^telcom-parser | wc -l)" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "web_count=$(git diff --name-only $lcommit...HEAD | grep -e ^daemon/web | wc -l)" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "docs_count=$(git diff --name-only $lcommit...HEAD | grep -e ^book.toml -e ^doc | wc -l)" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "installer_count=$(git diff --name-only $lcommit...HEAD | grep -e ^installer | wc -l)" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "rootshell_count=$(git diff --name-only $lcommit...HEAD | grep -e ^rootshell | wc -l)" >> "$GITHUB_OUTPUT"
|
||||||
|
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'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- name: Check formatting
|
||||||
|
run: cargo fmt --all --check
|
||||||
|
- name: Check
|
||||||
|
run: |
|
||||||
|
pushd daemon/web
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
popd
|
||||||
|
NO_FIRMWARE_BIN=true cargo check --verbose
|
||||||
|
- name: Run tests
|
||||||
|
run: |
|
||||||
|
NO_FIRMWARE_BIN=true cargo test --verbose
|
||||||
|
- name: Run clippy
|
||||||
|
run: |
|
||||||
|
NO_FIRMWARE_BIN=true cargo clippy --verbose
|
||||||
|
|
||||||
|
test_web_frontend:
|
||||||
|
needs: files_changed
|
||||||
|
if: needs.files_changed.outputs.web_changed != '0'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: daemon/web
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: npm install
|
||||||
|
- run: npm run lint
|
||||||
|
- run: npm run check
|
||||||
|
- run: npm run test
|
||||||
|
|
||||||
|
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: linux-x64
|
||||||
|
os: ubuntu-latest
|
||||||
|
target: x86_64-unknown-linux-musl
|
||||||
|
- name: linux-armv7
|
||||||
|
os: ubuntu-latest
|
||||||
|
target: armv7-unknown-linux-musleabi
|
||||||
|
- name: linux-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-latest
|
||||||
|
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: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: ${{ matrix.platform.target }}
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- name: Build rayhunter-check
|
||||||
|
run: cargo build --bin rayhunter-check --release --target ${{ matrix.platform.target }}
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: rayhunter-check-${{ matrix.platform.name }}
|
||||||
|
path: target/${{ matrix.platform.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 (armv7)
|
||||||
|
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
|
||||||
|
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 (armv7)
|
||||||
|
run: |
|
||||||
|
pushd daemon/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
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: rayhunter-daemon
|
||||||
|
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: linux-x64
|
||||||
|
os: ubuntu-latest
|
||||||
|
target: x86_64-unknown-linux-musl
|
||||||
|
- name: linux-armv7
|
||||||
|
os: ubuntu-latest
|
||||||
|
target: armv7-unknown-linux-musleabi
|
||||||
|
- name: linux-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-latest
|
||||||
|
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 --package installer --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
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
platform:
|
||||||
|
- linux-x64
|
||||||
|
- linux-aarch64
|
||||||
|
- linux-armv7
|
||||||
|
- macos-intel
|
||||||
|
- macos-arm
|
||||||
|
- windows-x86_64
|
||||||
|
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' daemon/Cargo.toml | head -n 1 | cut -d'"' -f2)" >> $GITHUB_ENV
|
||||||
|
- name: Setup versioned release directory
|
||||||
|
run: |
|
||||||
|
platform="${{ matrix.platform }}"
|
||||||
|
dest="rayhunter-v${{ env.VERSION }}-${{ matrix.platform }}"
|
||||||
|
mkdir "$dest"
|
||||||
|
# Handle installer with proper extension for Windows
|
||||||
|
if [ "$platform" = "windows-x86_64" ]; then
|
||||||
|
mv installer-$platform/installer.exe "$dest"/installer.exe
|
||||||
|
else
|
||||||
|
mv installer-$platform/installer "$dest"/installer
|
||||||
|
fi
|
||||||
|
cp -r rayhunter-check-* rayhunter-daemon rootshell/rootshell dist/* installer/install.ps1 "$dest"/
|
||||||
|
zip -r "$dest.zip" "$dest"
|
||||||
|
sha256sum "$dest.zip" > "$dest.zip.sha256"
|
||||||
|
|
||||||
|
- name: Upload zip release and sha256
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: rayhunter-v${{ env.VERSION }}-${{ matrix.platform }}
|
||||||
|
path: |
|
||||||
|
rayhunter-v${{ env.VERSION }}-${{ matrix.platform }}.zip
|
||||||
|
rayhunter-v${{ env.VERSION }}-${{ matrix.platform }}.zip.sha256
|
||||||
|
if-no-files-found: error
|
||||||
47
.github/workflows/mdbook.yaml
vendored
47
.github/workflows/mdbook.yaml
vendored
@@ -1,47 +0,0 @@
|
|||||||
# On Repository Settings > Pages > Build and deployment
|
|
||||||
# Set "Source" to GitHub Actions.
|
|
||||||
name: Documentation
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: ["main"]
|
|
||||||
pull_request:
|
|
||||||
branches: ["main"]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
mdbook_test:
|
|
||||||
name: Test mdBook Documentation builds
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Install mdBook
|
|
||||||
run: |
|
|
||||||
cargo install mdbook --no-default-features --features search --vers "^0.4" --locked
|
|
||||||
- name: Test mdBook
|
|
||||||
run: mdbook test
|
|
||||||
|
|
||||||
mdbook_publish:
|
|
||||||
if: ${{ github.event_name != 'pull_request' }}
|
|
||||||
needs: mdbook_test
|
|
||||||
permissions:
|
|
||||||
pages: write
|
|
||||||
contents: write
|
|
||||||
id-token: write
|
|
||||||
name: Publish mdBook to Github Pages
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Install mdBook
|
|
||||||
run: |
|
|
||||||
cargo install mdbook --no-default-features --features search --vers "^0.4" --locked
|
|
||||||
|
|
||||||
- name: Build mdBook
|
|
||||||
run: mdbook build
|
|
||||||
|
|
||||||
- name: Setup Pages
|
|
||||||
uses: actions/configure-pages@v4
|
|
||||||
- name: Upload artifact
|
|
||||||
uses: actions/upload-pages-artifact@v3
|
|
||||||
with:
|
|
||||||
path: book
|
|
||||||
- name: Deploy to Github Pages
|
|
||||||
uses: actions/deploy-pages@v4
|
|
||||||
48
.github/workflows/release.yml
vendored
Normal file
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 check daemon installer rootshell telcom-parser -name Cargo.toml -exec grep ^version {} \; | sort -u | wc -l)
|
||||||
|
find lib check daemon installer rootshell telcom-parser -name Cargo.toml -exec grep ^version {} \;
|
||||||
|
echo number of defined versions = $defined_versions
|
||||||
|
if [ $defined_versions != "1" ]
|
||||||
|
then
|
||||||
|
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-v${version}*.zi*
|
||||||
872
Cargo.lock
generated
872
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
members = [
|
members = [
|
||||||
"lib",
|
"lib",
|
||||||
"bin",
|
"daemon",
|
||||||
|
"check",
|
||||||
"rootshell",
|
"rootshell",
|
||||||
"telcom-parser",
|
"telcom-parser",
|
||||||
"installer",
|
"installer",
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -1,7 +1,19 @@
|
|||||||
|
# Rayhunter
|
||||||
|

|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
# Rayhunter
|
Rayhunter is a project for detecting IMSI catchers, also known as cell-site simulators or stingrays. It was first designed to run on a cheap mobile hotspot called the Orbic RC400L, but thanks to community efforts can [support some other devices as well](https://efforg.github.io/rayhunter/supported-devices.html).
|
||||||
|
It's also designed to be as easy to install and use as possible, regardless of your level of technical skills, and to minimize false positives.
|
||||||
|
|
||||||

|
→ Check out the [installation guide](https://efforg.github.io/rayhunter/installation.html) to get started.
|
||||||
|
|
||||||
Rayhunter is an IMSI Catcher Catcher for the Orbic mobile hotspot. To learn more, check out the [Rayhunter Book](https://efforg.github.io/rayhunter/).
|
→ To learn more about the aim of the project, and about IMSI catchers in general, please check out our [introductory blog post](https://www.eff.org/deeplinks/2025/03/meet-rayhunter-new-open-source-tool-eff-detect-cellular-spying).
|
||||||
|
|
||||||
|
→ For discussion, help, or to join the mattermost channel and get involved with the project and community check out the [many ways listed here](https://efforg.github.io/rayhunter/support-feedback-community.html)!
|
||||||
|
|
||||||
|
→ To learn more about the project in general check out the [Rayhunter Book](https://efforg.github.io/rayhunter/).
|
||||||
|
|
||||||
|
**LEGAL DISCLAIMER:** Use this program at your own risk. We believe running this program does not currently violate any laws or regulations in the United States. However, we are not responsible for civil or criminal liability resulting from the use of this software. If you are located outside of the US please consult with an attorney in your country to help you assess the legal risks of running this program.
|
||||||
|
|
||||||
|
*Good Hunting!*
|
||||||
|
|||||||
176
bin/src/check.rs
176
bin/src/check.rs
@@ -1,176 +0,0 @@
|
|||||||
use clap::Parser;
|
|
||||||
use futures::TryStreamExt;
|
|
||||||
use log::{info, warn};
|
|
||||||
use rayhunter::{
|
|
||||||
analysis::analyzer::{EventType, Harness},
|
|
||||||
diag::DataType,
|
|
||||||
gsmtap_parser,
|
|
||||||
pcap::GsmtapPcapWriter,
|
|
||||||
qmdl::QmdlReader,
|
|
||||||
};
|
|
||||||
use std::{collections::HashMap, future, path::PathBuf, pin::pin};
|
|
||||||
use tokio::fs::{metadata, read_dir, File};
|
|
||||||
|
|
||||||
mod dummy_analyzer;
|
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
|
||||||
#[command(version, about)]
|
|
||||||
struct Args {
|
|
||||||
#[arg(short = '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_all_analyzers();
|
|
||||||
if enable_dummy_analyzer {
|
|
||||||
harness.add_analyzer(Box::new(dummy_analyzer::TestAnalyzer { count: 0 }));
|
|
||||||
}
|
|
||||||
let qmdl_file = &mut File::open(&qmdl_path).await.expect("failed to open file");
|
|
||||||
let file_size = qmdl_file
|
|
||||||
.metadata()
|
|
||||||
.await
|
|
||||||
.expect("failed to get QMDL file metadata")
|
|
||||||
.len();
|
|
||||||
let 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() {
|
|
||||||
let args = Args::parse();
|
|
||||||
let level = if args.verbose {
|
|
||||||
log::LevelFilter::Trace
|
|
||||||
} else {
|
|
||||||
log::LevelFilter::Warn
|
|
||||||
};
|
|
||||||
simple_logger::SimpleLogger::new()
|
|
||||||
.with_colors(true)
|
|
||||||
.without_timestamps()
|
|
||||||
.with_level(level)
|
|
||||||
.init()
|
|
||||||
.unwrap();
|
|
||||||
info!("Analyzers:");
|
|
||||||
|
|
||||||
let mut harness = Harness::new_with_all_analyzers();
|
|
||||||
if args.enable_dummy_analyzer {
|
|
||||||
harness.add_analyzer(Box::new(dummy_analyzer::TestAnalyzer { count: 0 }));
|
|
||||||
}
|
|
||||||
for analyzer in harness.get_metadata().analyzers {
|
|
||||||
info!(" - {}: {}", analyzer.name, analyzer.description);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
325
bin/src/diag.rs
325
bin/src/diag.rs
@@ -1,325 +0,0 @@
|
|||||||
use std::pin::pin;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use axum::body::Body;
|
|
||||||
use axum::extract::{Path, State};
|
|
||||||
use axum::http::header::CONTENT_TYPE;
|
|
||||||
use axum::http::StatusCode;
|
|
||||||
use axum::response::{IntoResponse, Response};
|
|
||||||
use futures::{StreamExt, TryStreamExt};
|
|
||||||
use log::{debug, error, info};
|
|
||||||
use rayhunter::diag::DataType;
|
|
||||||
use rayhunter::diag_device::DiagDevice;
|
|
||||||
use rayhunter::qmdl::QmdlWriter;
|
|
||||||
use tokio::fs::File;
|
|
||||||
use tokio::sync::mpsc::{Receiver, Sender};
|
|
||||||
use tokio::sync::RwLock;
|
|
||||||
use tokio_util::io::ReaderStream;
|
|
||||||
use tokio_util::task::TaskTracker;
|
|
||||||
|
|
||||||
use 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)),
|
|
||||||
Exit,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn run_diag_read_thread(
|
|
||||||
task_tracker: &TaskTracker,
|
|
||||||
mut dev: DiagDevice,
|
|
||||||
mut qmdl_file_rx: Receiver<DiagDeviceCtrlMessage>,
|
|
||||||
ui_update_sender: Sender<display::DisplayState>,
|
|
||||||
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
|
|
||||||
enable_dummy_analyzer: bool,
|
|
||||||
) {
|
|
||||||
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, enable_dummy_analyzer).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);
|
|
||||||
if let Some(analysis_writer) = maybe_analysis_writer {
|
|
||||||
analysis_writer.close().await.expect("failed to close analysis writer");
|
|
||||||
}
|
|
||||||
maybe_analysis_writer = Some(AnalysisWriter::new(new_analysis_file, enable_dummy_analyzer).await
|
|
||||||
.expect("failed to write to analysis file"));
|
|
||||||
},
|
|
||||||
Some(DiagDeviceCtrlMessage::StopRecording) => {
|
|
||||||
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;
|
|
||||||
},
|
|
||||||
// None means all the Senders have been dropped, so it's
|
|
||||||
// time to go
|
|
||||||
Some(DiagDeviceCtrlMessage::Exit) | None => {
|
|
||||||
info!("Diag reader thread exiting...");
|
|
||||||
if let Some(analysis_writer) = maybe_analysis_writer {
|
|
||||||
analysis_writer.close().await.expect("failed to close analysis writer");
|
|
||||||
}
|
|
||||||
return Ok(())
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
maybe_container = diag_stream.next() => {
|
|
||||||
match maybe_container.unwrap() {
|
|
||||||
Ok(container) => {
|
|
||||||
if container.data_type != DataType::UserSpace {
|
|
||||||
debug!("skipping non-userspace diag messages...");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// keep track of how many bytes were written to the QMDL file so we can read
|
|
||||||
// a valid block of data from it in the HTTP server
|
|
||||||
if let Some(qmdl_writer) = maybe_qmdl_writer.as_mut() {
|
|
||||||
qmdl_writer.write_container(&container).await.expect("failed to write to QMDL writer");
|
|
||||||
debug!("total QMDL bytes written: {}, updating manifest...", qmdl_writer.total_written);
|
|
||||||
let mut qmdl_store = qmdl_store_lock.write().await;
|
|
||||||
let index = qmdl_store.current_entry.expect("DiagDevice had qmdl_writer, but QmdlStore didn't have current entry???");
|
|
||||||
qmdl_store.update_entry_qmdl_size(index, qmdl_writer.total_written).await
|
|
||||||
.expect("failed to update qmdl file size");
|
|
||||||
debug!("done!");
|
|
||||||
} else {
|
|
||||||
debug!("no qmdl_writer set, continuing...");
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(analysis_writer) = maybe_analysis_writer.as_mut() {
|
|
||||||
let 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).await
|
|
||||||
.expect("failed to update analysis file size");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(err) => {
|
|
||||||
error!("error reading diag device: {}", err);
|
|
||||||
return Err(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn start_recording(
|
|
||||||
State(state): State<Arc<ServerState>>,
|
|
||||||
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
|
||||||
if state.debug_mode {
|
|
||||||
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
|
|
||||||
}
|
|
||||||
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),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let display_state = display::DisplayState::Recording;
|
|
||||||
state
|
|
||||||
.ui_update_sender
|
|
||||||
.send(display_state)
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
format!("couldn't send ui update message: {}", e),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok((StatusCode::ACCEPTED, "ok".to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn stop_recording(
|
|
||||||
State(state): State<Arc<ServerState>>,
|
|
||||||
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
|
||||||
if state.debug_mode {
|
|
||||||
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
|
|
||||||
}
|
|
||||||
let mut qmdl_store = state.qmdl_store_lock.write().await;
|
|
||||||
match qmdl_store.get_current_entry() {
|
|
||||||
Some((_, entry)) => {
|
|
||||||
state
|
|
||||||
.analysis_sender
|
|
||||||
.send(AnalysisCtrlMessage::RecordingFinished(
|
|
||||||
entry.name.to_string(),
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
format!("couldn't send AnalysisCtrlMessage: {}", e),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
None => todo!(),
|
|
||||||
}
|
|
||||||
qmdl_store.close_current_entry().await.map_err(|e| {
|
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
format!("couldn't close current qmdl entry: {}", e),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
state
|
|
||||||
.diag_device_ctrl_sender
|
|
||||||
.send(DiagDeviceCtrlMessage::StopRecording)
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
format!("couldn't send stop recording message: {}", e),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
state
|
|
||||||
.ui_update_sender
|
|
||||||
.send(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_recording(
|
|
||||||
State(state): State<Arc<ServerState>>,
|
|
||||||
Path(qmdl_name): Path<String>,
|
|
||||||
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
|
||||||
if state.debug_mode {
|
|
||||||
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
|
|
||||||
}
|
|
||||||
let mut qmdl_store = state.qmdl_store_lock.write().await;
|
|
||||||
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.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 (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(),
|
|
||||||
))?
|
|
||||||
} 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_index)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)))?;
|
|
||||||
let analysis_stream = ReaderStream::new(analysis_file);
|
|
||||||
|
|
||||||
let headers = [(CONTENT_TYPE, "application/x-ndjson")];
|
|
||||||
let body = Body::from_stream(analysis_stream);
|
|
||||||
Ok((headers, body).into_response())
|
|
||||||
}
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
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));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
mod generic_framebuffer;
|
|
||||||
|
|
||||||
#[cfg(feature = "tplink")]
|
|
||||||
mod tplink;
|
|
||||||
#[cfg(feature = "tplink")]
|
|
||||||
mod tplink_framebuffer;
|
|
||||||
#[cfg(feature = "tplink")]
|
|
||||||
mod tplink_onebit;
|
|
||||||
|
|
||||||
#[cfg(feature = "tplink")]
|
|
||||||
pub use tplink::update_ui;
|
|
||||||
|
|
||||||
#[cfg(feature = "orbic")]
|
|
||||||
mod orbic;
|
|
||||||
#[cfg(feature = "orbic")]
|
|
||||||
pub use orbic::update_ui;
|
|
||||||
|
|
||||||
pub enum DisplayState {
|
|
||||||
Recording,
|
|
||||||
Paused,
|
|
||||||
WarningDetected,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(feature = "orbic", feature = "tplink"))]
|
|
||||||
compile_error!("cannot compile for many devices at once");
|
|
||||||
|
|
||||||
#[cfg(not(any(feature = "orbic", feature = "tplink")))]
|
|
||||||
compile_error!("cannot compile for no device at all");
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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,78 +0,0 @@
|
|||||||
use axum::body::Body;
|
|
||||||
use axum::extract::Path;
|
|
||||||
use axum::extract::State;
|
|
||||||
use axum::http::header::{self, CONTENT_LENGTH, CONTENT_TYPE};
|
|
||||||
use axum::http::{HeaderValue, StatusCode};
|
|
||||||
use axum::response::{IntoResponse, Response};
|
|
||||||
use include_dir::{include_dir, Dir};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::io::AsyncReadExt;
|
|
||||||
use tokio::sync::mpsc::Sender;
|
|
||||||
use tokio::sync::RwLock;
|
|
||||||
use tokio_util::io::ReaderStream;
|
|
||||||
|
|
||||||
use crate::analysis::{AnalysisCtrlMessage, AnalysisStatus};
|
|
||||||
use crate::qmdl_store::RecordingStore;
|
|
||||||
use crate::{display, DiagDeviceCtrlMessage};
|
|
||||||
|
|
||||||
pub struct ServerState {
|
|
||||||
pub qmdl_store_lock: Arc<RwLock<RecordingStore>>,
|
|
||||||
pub diag_device_ctrl_sender: Sender<DiagDeviceCtrlMessage>,
|
|
||||||
pub ui_update_sender: Sender<display::DisplayState>,
|
|
||||||
pub analysis_status_lock: Arc<RwLock<AnalysisStatus>>,
|
|
||||||
pub analysis_sender: Sender<AnalysisCtrlMessage>,
|
|
||||||
pub debug_mode: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_qmdl(
|
|
||||||
State(state): State<Arc<ServerState>>,
|
|
||||||
Path(qmdl_name): Path<String>,
|
|
||||||
) -> Result<Response, (StatusCode, String)> {
|
|
||||||
let qmdl_idx = qmdl_name.trim_end_matches(".qmdl");
|
|
||||||
let qmdl_store = state.qmdl_store_lock.read().await;
|
|
||||||
let (entry_index, entry) = qmdl_store.entry_for_name(qmdl_idx).ok_or((
|
|
||||||
StatusCode::NOT_FOUND,
|
|
||||||
format!("couldn't find qmdl file with name {}", qmdl_idx),
|
|
||||||
))?;
|
|
||||||
let qmdl_file = qmdl_store.open_entry_qmdl(entry_index).await.map_err(|e| {
|
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
format!("error opening QMDL file: {}", e),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let limited_qmdl_file = qmdl_file.take(entry.qmdl_size_bytes as u64);
|
|
||||||
let qmdl_stream = ReaderStream::new(limited_qmdl_file);
|
|
||||||
|
|
||||||
let headers = [
|
|
||||||
(CONTENT_TYPE, "application/octet-stream"),
|
|
||||||
(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/web/build");
|
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
match STATIC_DIR.get_file(path) {
|
|
||||||
None => Response::builder()
|
|
||||||
.status(StatusCode::NOT_FOUND)
|
|
||||||
.body(Body::empty())
|
|
||||||
.unwrap(),
|
|
||||||
Some(file) => Response::builder()
|
|
||||||
.status(StatusCode::OK)
|
|
||||||
.header(
|
|
||||||
header::CONTENT_TYPE,
|
|
||||||
HeaderValue::from_str(mime_type.as_ref()).unwrap(),
|
|
||||||
)
|
|
||||||
.body(Body::from(file.contents()))
|
|
||||||
.unwrap(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"useTabs": true,
|
|
||||||
"singleQuote": true,
|
|
||||||
"trailingComma": "none",
|
|
||||||
"printWidth": 100,
|
|
||||||
"plugins": [
|
|
||||||
"prettier-plugin-svelte"
|
|
||||||
],
|
|
||||||
"overrides": [
|
|
||||||
{
|
|
||||||
"files": "*.svelte",
|
|
||||||
"options": {
|
|
||||||
"parser": "svelte"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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/"]
|
|
||||||
}
|
|
||||||
);
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
@import "tailwindcss/base";
|
|
||||||
@import "tailwindcss/components";
|
|
||||||
@import "tailwindcss/utilities"
|
|
||||||
13
bin/web/src/app.d.ts
vendored
13
bin/web/src/app.d.ts
vendored
@@ -1,13 +0,0 @@
|
|||||||
// 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 {};
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
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';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
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 === "Informational") {
|
|
||||||
num_informational_logs += 1;
|
|
||||||
return {
|
|
||||||
type: EventType.Informational,
|
|
||||||
message: event_json.message,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
num_warnings += 1;
|
|
||||||
return {
|
|
||||||
type: EventType.Warning,
|
|
||||||
severity: event_json.severity === "High" ? Severity.High :
|
|
||||||
event_json.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);
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
<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}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Manifest, ManifestEntry } from "$lib/manifest.svelte";
|
|
||||||
import TableRow from "./ManifestTableRow.svelte";
|
|
||||||
import Card from "./ManifestCard.svelte"
|
|
||||||
interface Props {
|
|
||||||
entries: ManifestEntry[];
|
|
||||||
server_is_recording: boolean;
|
|
||||||
}
|
|
||||||
let { entries, server_is_recording }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!--For larger screens we use a table-->
|
|
||||||
<table class="hidden table-auto text-left lg:table">
|
|
||||||
<thead>
|
|
||||||
<tr class="bg-gray-100 drop-shadow">
|
|
||||||
<th class='p-2' scope="col">ID</th>
|
|
||||||
<th class='p-2' scope="col">Started</th>
|
|
||||||
<th class='p-2' scope="col">Last Message</th>
|
|
||||||
<th class='p-2' scope="col">Size</th>
|
|
||||||
<th class='p-2' scope="col">PCAP</th>
|
|
||||||
<th class='p-2' scope="col">QMDL</th>
|
|
||||||
<th class='p-2' scope="col">Analysis</th>
|
|
||||||
<th class='p-2' scope="col"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each entries as entry, i}
|
|
||||||
<TableRow {entry} current={false} {i} />
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<!--For smaller screens we use cards-->
|
|
||||||
<div class="lg:hidden flex flex-col gap-4">
|
|
||||||
{#each entries as entry, i}
|
|
||||||
<Card {entry} current={false} {i} />
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { ManifestEntry } from "$lib/manifest.svelte";
|
|
||||||
import DownloadLink from '$lib/components/DownloadLink.svelte';
|
|
||||||
import DeleteButton from "$lib/components/DeleteButton.svelte";
|
|
||||||
import AnalysisStatus from "./AnalysisStatus.svelte";
|
|
||||||
import AnalysisView from "./AnalysisView.svelte";
|
|
||||||
let { entry, current, i }: {
|
|
||||||
entry: ManifestEntry;
|
|
||||||
current: boolean;
|
|
||||||
i: number;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
// passing `undefined` as the locale uses the browser default
|
|
||||||
const date_formatter = new Intl.DateTimeFormat(undefined, {
|
|
||||||
timeStyle: "long",
|
|
||||||
dateStyle: "short",
|
|
||||||
});
|
|
||||||
let alternating_row_color = $derived(i % 2 == 0 ? "bg-white" : "bg-gray-100");
|
|
||||||
let status_row_color = $derived.by(() => {
|
|
||||||
const num_warnings = entry.get_num_warnings();
|
|
||||||
if (num_warnings !== undefined && num_warnings > 0) {
|
|
||||||
return "bg-red-100";
|
|
||||||
}
|
|
||||||
return current ? "bg-green-100" : alternating_row_color
|
|
||||||
});
|
|
||||||
let analysis_visible = $state(false);
|
|
||||||
function toggle_analysis_visibility() {
|
|
||||||
analysis_visible = !analysis_visible;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<tr class="{status_row_color} drop-shadow">
|
|
||||||
<td class="p-2">{entry.name}</td>
|
|
||||||
<td class="p-2">{date_formatter.format(entry.start_time)}</td>
|
|
||||||
<td class="p-2">{date_formatter.format(entry.last_message_time)}</td>
|
|
||||||
<td class="p-2">{entry.get_readable_qmdl_size()}</td>
|
|
||||||
<td class="p-2"><DownloadLink url={entry.get_pcap_url()} text="pcap" /></td>
|
|
||||||
<td class="p-2"><DownloadLink url={entry.get_qmdl_url()} text="qmdl" /></td>
|
|
||||||
<td class="p-2"><AnalysisStatus onclick={toggle_analysis_visibility} entry={entry} analysis_visible={analysis_visible}/></td>
|
|
||||||
{#if current}
|
|
||||||
<td class="p-2"></td>
|
|
||||||
{:else}
|
|
||||||
<td class="p-2">
|
|
||||||
<DeleteButton
|
|
||||||
prompt={`Are you sure you want to delete entry ${entry.name}?`}
|
|
||||||
url={entry.get_delete_url()}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
{/if}
|
|
||||||
</tr>
|
|
||||||
<tr class="{alternating_row_color} border-b {analysis_visible ? '' : 'hidden'}">
|
|
||||||
<td class="border-t border-dashed p-2" colspan="8">
|
|
||||||
<AnalysisView {entry} />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<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} / {stats.disk_stats.available_size})
|
|
||||||
</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,26 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { Manifest } from "./manifest.svelte";
|
|
||||||
import type { SystemStats } from "./systemStats";
|
|
||||||
|
|
||||||
export async function req(method: string, url: string): Promise<string> {
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: method,
|
|
||||||
});
|
|
||||||
const body = await response.text();
|
|
||||||
if (response.status >= 200 && response.status < 300) {
|
|
||||||
return body;
|
|
||||||
} else {
|
|
||||||
throw new Error(body);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function get_manifest(): Promise<Manifest> {
|
|
||||||
const manifest_json = JSON.parse(await req('GET', '/api/qmdl-manifest'));
|
|
||||||
return new Manifest(manifest_json);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function get_system_stats(): Promise<SystemStats> {
|
|
||||||
return JSON.parse(await req('GET', '/api/system-stats'));
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import '../app.css';
|
|
||||||
let { children } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{@render children()}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { ManifestEntry } from "$lib/manifest.svelte";
|
|
||||||
import { get_manifest, get_system_stats } from "$lib/utils.svelte";
|
|
||||||
import ManifestTable from "$lib/components/ManifestTable.svelte";
|
|
||||||
import Card from "$lib/components/ManifestCard.svelte";
|
|
||||||
import type { SystemStats } from "$lib/systemStats";
|
|
||||||
import { AnalysisManager } from "$lib/analysisManager.svelte";
|
|
||||||
import SystemStatsTable from "$lib/components/SystemStatsTable.svelte";
|
|
||||||
import DeleteAllButton from "$lib/components/DeleteAllButton.svelte";
|
|
||||||
import RecordingControls from "$lib/components//RecordingControls.svelte";
|
|
||||||
|
|
||||||
let manager: AnalysisManager = new AnalysisManager();
|
|
||||||
let loaded = $state(false);
|
|
||||||
let recording = $state(false);
|
|
||||||
let entries: ManifestEntry[] = $state([]);
|
|
||||||
let current_entry: ManifestEntry | undefined = $state(undefined);
|
|
||||||
let system_stats: SystemStats | undefined = $state(undefined);
|
|
||||||
$effect(() => {
|
|
||||||
const interval = setInterval(async () => {
|
|
||||||
await manager.update();
|
|
||||||
let new_manifest = await get_manifest();
|
|
||||||
await new_manifest.set_analysis_status(manager);
|
|
||||||
entries = new_manifest.entries;
|
|
||||||
current_entry = new_manifest.current_entry;
|
|
||||||
recording = current_entry !== undefined;
|
|
||||||
|
|
||||||
system_stats = await get_system_stats();
|
|
||||||
loaded = true;
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="p-4 xl:px-8 bg-rayhunter-blue drop-shadow flex flex-row justify-between items-center">
|
|
||||||
<img src="/rayhunter_text.png" class="h-10 xl:h-12"/>
|
|
||||||
<div class="flex flex-row gap-4">
|
|
||||||
<a class="flex flex-row gap-1 group" href="https://github.com/EFForg/rayhunter/issues" target="_blank">
|
|
||||||
<span class="hidden text-white group-hover:text-gray-400 lg:flex">Report Issue</span>
|
|
||||||
<svg class="w-6 h-6 text-white group-hover:text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path fill-rule="evenodd" d="M12.006 2a9.847 9.847 0 0 0-6.484 2.44 10.32 10.32 0 0 0-3.393 6.17 10.48 10.48 0 0 0 1.317 6.955 10.045 10.045 0 0 0 5.4 4.418c.504.095.683-.223.683-.494 0-.245-.01-1.052-.014-1.908-2.78.62-3.366-1.21-3.366-1.21a2.711 2.711 0 0 0-1.11-1.5c-.907-.637.07-.621.07-.621.317.044.62.163.885.346.266.183.487.426.647.71.135.253.318.476.538.655a2.079 2.079 0 0 0 2.37.196c.045-.52.27-1.006.635-1.37-2.219-.259-4.554-1.138-4.554-5.07a4.022 4.022 0 0 1 1.031-2.75 3.77 3.77 0 0 1 .096-2.713s.839-.275 2.749 1.05a9.26 9.26 0 0 1 5.004 0c1.906-1.325 2.74-1.05 2.74-1.05.37.858.406 1.828.101 2.713a4.017 4.017 0 0 1 1.029 2.75c0 3.939-2.339 4.805-4.564 5.058a2.471 2.471 0 0 1 .679 1.897c0 1.372-.012 2.477-.012 2.814 0 .272.18.592.687.492a10.05 10.05 0 0 0 5.388-4.421 10.473 10.473 0 0 0 1.313-6.948 10.32 10.32 0 0 0-3.39-6.165A9.847 9.847 0 0 0 12.007 2Z" clip-rule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<a class="flex flex-row gap-1 group" href="https://efforg.github.io/rayhunter/" target="_blank">
|
|
||||||
<span class="hidden text-white group-hover:text-gray-400 lg:flex">Docs</span>
|
|
||||||
<svg class="w-6 h-6 text-white group-hover:text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
|
||||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 19V4a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v13H7a2 2 0 0 0-2 2Zm0 0a2 2 0 0 0 2 2h12M9 3v14m7 0v4"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="m-4 xl:mx-8 flex flex-col gap-4">
|
|
||||||
{#if loaded}
|
|
||||||
<div class="flex flex-col lg:flex-row gap-4">
|
|
||||||
{#if recording}
|
|
||||||
<Card entry={current_entry} current={true} i={0} server_is_recording={recording}/>
|
|
||||||
{:else}
|
|
||||||
<div class="bg-red-100 border-red-100 drop-shadow p-4 flex flex-col gap-2 border rounded-md flex-1 justify-between">
|
|
||||||
<span class="text-2xl font-bold mb-2 flex flex-row items-center gap-2 text-red-600">
|
|
||||||
<svg class="w-8 h-8 text-red-600" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path fill-rule="evenodd" d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm11-4a1 1 0 1 0-2 0v5a1 1 0 1 0 2 0V8Zm-1 7a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H12Z" clip-rule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
WARNING: Not Running
|
|
||||||
</span>
|
|
||||||
<span>Rayhunter is not currently running and will not detect abnormal behavior!</span>
|
|
||||||
<div class="flex flex-row justify-end mt-2">
|
|
||||||
<RecordingControls {recording} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<SystemStatsTable stats={system_stats!} />
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<span class="text-xl">History</span>
|
|
||||||
<ManifestTable entries={entries} server_is_recording={recording} />
|
|
||||||
</div>
|
|
||||||
<DeleteAllButton/>
|
|
||||||
{:else}
|
|
||||||
<div class="flex flex-col justify-center items-center">
|
|
||||||
<img src="/rayhunter_orca_only.png" class="h-48 animate-spin"/>
|
|
||||||
<p class="text-xl">Loading...</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
4
bin/web/static/pico.min.css
vendored
4
bin/web/static/pico.min.css
vendored
File diff suppressed because one or more lines are too long
@@ -1,15 +0,0 @@
|
|||||||
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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
@@ -3,3 +3,6 @@ authors = ["The Rayhunter Team"]
|
|||||||
language = "en"
|
language = "en"
|
||||||
src = "doc"
|
src = "doc"
|
||||||
title = "Rayhunter - An IMSI Catcher Catcher"
|
title = "Rayhunter - An IMSI Catcher Catcher"
|
||||||
|
|
||||||
|
[output.html]
|
||||||
|
edit-url-template = "https://github.com/efforg/rayhunter/edit/main/{path}"
|
||||||
|
|||||||
14
check/Cargo.toml
Normal file
14
check/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "rayhunter-check"
|
||||||
|
version = "0.6.1"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
rayhunter = { path = "../lib" }
|
||||||
|
futures = { version = "0.3.30", default-features = false }
|
||||||
|
log = "0.4.20"
|
||||||
|
tokio = { version = "1.44.2", default-features = false, features = ["fs", "signal", "process", "rt-multi-thread"] }
|
||||||
|
pcap-file-tokio = "0.1.0"
|
||||||
|
clap = { version = "4.5.2", features = ["derive"] }
|
||||||
|
simple_logger = "5.0.0"
|
||||||
|
walkdir = "2.5.0"
|
||||||
221
check/src/main.rs
Normal file
221
check/src/main.rs
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
use clap::Parser;
|
||||||
|
use futures::TryStreamExt;
|
||||||
|
use log::{debug, error, info, warn};
|
||||||
|
use pcap_file_tokio::pcapng::{Block, PcapNgReader};
|
||||||
|
use rayhunter::{
|
||||||
|
analysis::analyzer::{AnalysisRow, AnalyzerConfig, EventType, Harness},
|
||||||
|
diag::DataType,
|
||||||
|
gsmtap_parser,
|
||||||
|
pcap::GsmtapPcapWriter,
|
||||||
|
qmdl::QmdlReader,
|
||||||
|
};
|
||||||
|
use std::{collections::HashMap, future, path::PathBuf, pin::pin};
|
||||||
|
use tokio::fs::File;
|
||||||
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(version, about)]
|
||||||
|
struct Args {
|
||||||
|
#[arg(short = 'p', long)]
|
||||||
|
path: PathBuf,
|
||||||
|
|
||||||
|
#[arg(short = 'P', long)]
|
||||||
|
pcapify: bool,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
show_skipped: bool,
|
||||||
|
|
||||||
|
#[arg(short, long)]
|
||||||
|
quiet: bool,
|
||||||
|
|
||||||
|
#[arg(short, long)]
|
||||||
|
debug: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Report {
|
||||||
|
skipped_reasons: HashMap<String, u32>,
|
||||||
|
total_messages: u32,
|
||||||
|
warnings: u32,
|
||||||
|
skipped: u32,
|
||||||
|
file_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Report {
|
||||||
|
fn new(file_path: &str) -> Self {
|
||||||
|
Report {
|
||||||
|
file_path: file_path.to_string(),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_row(&mut self, row: AnalysisRow) {
|
||||||
|
self.total_messages += 1;
|
||||||
|
if let Some(reason) = row.skipped_message_reason {
|
||||||
|
*self.skipped_reasons.entry(reason).or_insert(0) += 1;
|
||||||
|
self.skipped += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for maybe_event in row.events {
|
||||||
|
let Some(event) = maybe_event else { continue };
|
||||||
|
let Some(timestamp) = row.packet_timestamp else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match event.event_type {
|
||||||
|
EventType::Informational => {
|
||||||
|
info!("{}: INFO - {} {}", self.file_path, timestamp, event.message,);
|
||||||
|
}
|
||||||
|
EventType::Low | EventType::Medium | EventType::High => {
|
||||||
|
warn!(
|
||||||
|
"{}: WARNING (Severity: {:?}) - {} {}",
|
||||||
|
self.file_path, event.event_type, timestamp, event.message,
|
||||||
|
);
|
||||||
|
self.warnings += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_summary(&self, show_skipped: bool) {
|
||||||
|
if show_skipped && self.skipped > 0 {
|
||||||
|
info!("{}: messages skipped:", self.file_path);
|
||||||
|
for (reason, count) in self.skipped_reasons.iter() {
|
||||||
|
info!(" - {count}: \"{reason}\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info!(
|
||||||
|
"{}: {} messages analyzed, {} warnings, {} messages skipped",
|
||||||
|
self.file_path, self.total_messages, self.warnings, self.skipped
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn analyze_pcap(pcap_path: &str, show_skipped: bool) {
|
||||||
|
let mut harness = Harness::new_with_config(&AnalyzerConfig::default());
|
||||||
|
let pcap_file = &mut File::open(&pcap_path).await.expect("failed to open file");
|
||||||
|
let mut pcap_reader = PcapNgReader::new(pcap_file)
|
||||||
|
.await
|
||||||
|
.expect("failed to read PCAP file");
|
||||||
|
let mut report = Report::new(pcap_path);
|
||||||
|
while let Some(Ok(block)) = pcap_reader.next_block().await {
|
||||||
|
let row = match block {
|
||||||
|
Block::EnhancedPacket(packet) => harness.analyze_pcap_packet(packet),
|
||||||
|
other => {
|
||||||
|
debug!("{pcap_path}: skipping pcap packet {other:?}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
report.process_row(row);
|
||||||
|
}
|
||||||
|
report.print_summary(show_skipped);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn analyze_qmdl(qmdl_path: &str, show_skipped: bool) {
|
||||||
|
let mut harness = Harness::new_with_config(&AnalyzerConfig::default());
|
||||||
|
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 report = Report::new(qmdl_path);
|
||||||
|
while let Some(container) = qmdl_stream
|
||||||
|
.try_next()
|
||||||
|
.await
|
||||||
|
.expect("failed getting QMDL container")
|
||||||
|
{
|
||||||
|
for row in harness.analyze_qmdl_messages(container) {
|
||||||
|
report.process_row(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
report.print_summary(show_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("pcapng");
|
||||||
|
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() {
|
||||||
|
let args = Args::parse();
|
||||||
|
let level = if args.debug {
|
||||||
|
log::LevelFilter::Debug
|
||||||
|
} else if args.quiet {
|
||||||
|
log::LevelFilter::Warn
|
||||||
|
} else {
|
||||||
|
log::LevelFilter::Info
|
||||||
|
};
|
||||||
|
simple_logger::SimpleLogger::new()
|
||||||
|
.with_colors(true)
|
||||||
|
.without_timestamps()
|
||||||
|
.with_level(level)
|
||||||
|
//Filter out a stupid massive amount of uneccesary warnings from hampi about undecoded extensions
|
||||||
|
.with_module_level("asn1_codecs", log::LevelFilter::Error)
|
||||||
|
.init()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let harness = Harness::new_with_config(&AnalyzerConfig::default());
|
||||||
|
info!("Analyzers:");
|
||||||
|
for analyzer in harness.get_metadata().analyzers {
|
||||||
|
info!(
|
||||||
|
" - {} (v{}): {}",
|
||||||
|
analyzer.name, analyzer.version, analyzer.description
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for maybe_entry in WalkDir::new(&args.path) {
|
||||||
|
let Ok(entry) = maybe_entry else {
|
||||||
|
error!("failed to open dir entry {maybe_entry:?}");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let name = entry.file_name();
|
||||||
|
let name_str = name.to_str().unwrap();
|
||||||
|
let path = entry.path();
|
||||||
|
let path_str = path.to_str().unwrap();
|
||||||
|
// instead of relying on the QMDL extension, can we check if a file is
|
||||||
|
// QMDL by inspecting the contents?
|
||||||
|
if name_str.ends_with(".qmdl") {
|
||||||
|
info!("**** Beginning analysis of {name_str}");
|
||||||
|
analyze_qmdl(path_str, args.show_skipped).await;
|
||||||
|
if args.pcapify {
|
||||||
|
pcapify(&path.to_path_buf()).await;
|
||||||
|
}
|
||||||
|
} else if name_str.ends_with(".pcap") || name_str.ends_with(".pcapng") {
|
||||||
|
// TODO: if we've already analyzed a QMDL, skip its corresponding pcap
|
||||||
|
info!("**** Beginning analysis of {name_str}");
|
||||||
|
analyze_pcap(path_str, args.show_skipped).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,42 +1,32 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rayhunter-daemon"
|
name = "rayhunter-daemon"
|
||||||
version = "0.3.2"
|
version = "0.6.1"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
rust-version = "1.88.0"
|
||||||
[features]
|
|
||||||
# These feature flags are mutually exclusive, and exactly one must be enabled.
|
|
||||||
orbic = ["rayhunter/orbic"]
|
|
||||||
tplink = ["rayhunter/tplink"]
|
|
||||||
|
|
||||||
default = ["orbic"]
|
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "rayhunter-daemon"
|
|
||||||
path = "src/daemon.rs"
|
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "rayhunter-check"
|
|
||||||
path = "src/check.rs"
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
rayhunter = { path = "../lib" }
|
rayhunter = { path = "../lib" }
|
||||||
toml = "0.8.8"
|
toml = "0.8.8"
|
||||||
serde = { version = "1.0.193", features = ["derive"] }
|
serde = { version = "1.0.193", features = ["derive"] }
|
||||||
tokio = { version = "1.44.2", default-features = false, features = ["fs", "signal", "process", "rt-multi-thread"] }
|
tokio = { version = "1.44.2", default-features = false, features = ["fs", "signal", "process", "rt"] }
|
||||||
axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json"] }
|
axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json"] }
|
||||||
thiserror = "1.0.52"
|
thiserror = "1.0.52"
|
||||||
libc = "0.2.150"
|
libc = "0.2.150"
|
||||||
log = "0.4.20"
|
log = "0.4.20"
|
||||||
env_logger = { version = "0.11", default-features = false }
|
env_logger = { version = "0.11", default-features = false }
|
||||||
tokio-util = { version = "0.7.10", features = ["rt", "io"] }
|
tokio-util = { version = "0.7.10", features = ["rt", "io", "compat"] }
|
||||||
futures-macro = "0.3.30"
|
futures-macro = "0.3.30"
|
||||||
include_dir = "0.7.3"
|
include_dir = "0.7.3"
|
||||||
mime_guess = "2.0.4"
|
|
||||||
chrono = { version = "0.4.31", features = ["serde"] }
|
chrono = { version = "0.4.31", features = ["serde"] }
|
||||||
tokio-stream = { version = "0.1.14", default-features = false }
|
tokio-stream = { version = "0.1.14", default-features = false, features = ["io-util"] }
|
||||||
futures = { version = "0.3.30", default-features = false }
|
futures = { version = "0.3.30", default-features = false }
|
||||||
clap = { version = "4.5.2", features = ["derive"] }
|
|
||||||
serde_json = "1.0.114"
|
serde_json = "1.0.114"
|
||||||
image = { version = "0.25.1", default-features = false, features = ["png", "gif"] }
|
image = { version = "0.25.1", default-features = false, features = ["png", "gif"] }
|
||||||
tempfile = "3.10.1"
|
tempfile = "3.10.1"
|
||||||
simple_logger = "5.0.0"
|
async_zip = { version = "0.0.17", features = ["tokio"] }
|
||||||
|
anyhow = "1.0.98"
|
||||||
|
reqwest = { version = "0.12.20", default-features = false, features = [
|
||||||
|
"rustls-tls-webpki-roots-no-provider",
|
||||||
|
] }
|
||||||
|
rustls-rustcrypto = "0.0.2-alpha"
|
||||||
|
async-trait = "0.1.88"
|
||||||
|
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 |
@@ -1,5 +1,5 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::{future, pin};
|
use std::{cmp, future, pin};
|
||||||
|
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -7,8 +7,8 @@ use axum::{
|
|||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
};
|
};
|
||||||
use futures::TryStreamExt;
|
use futures::TryStreamExt;
|
||||||
use log::{debug, error, info};
|
use log::{error, info};
|
||||||
use rayhunter::analysis::analyzer::Harness;
|
use rayhunter::analysis::analyzer::{AnalyzerConfig, EventType, Harness};
|
||||||
use rayhunter::diag::{DataType, MessagesContainer};
|
use rayhunter::diag::{DataType, MessagesContainer};
|
||||||
use rayhunter::qmdl::QmdlReader;
|
use rayhunter::qmdl::QmdlReader;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -18,14 +18,12 @@ use tokio::sync::mpsc::Receiver;
|
|||||||
use tokio::sync::{RwLock, RwLockWriteGuard};
|
use tokio::sync::{RwLock, RwLockWriteGuard};
|
||||||
use tokio_util::task::TaskTracker;
|
use tokio_util::task::TaskTracker;
|
||||||
|
|
||||||
use crate::dummy_analyzer::TestAnalyzer;
|
|
||||||
use crate::qmdl_store::RecordingStore;
|
use crate::qmdl_store::RecordingStore;
|
||||||
use crate::server::ServerState;
|
use crate::server::ServerState;
|
||||||
|
|
||||||
pub struct AnalysisWriter {
|
pub struct AnalysisWriter {
|
||||||
writer: BufWriter<File>,
|
writer: BufWriter<File>,
|
||||||
harness: Harness,
|
harness: Harness,
|
||||||
bytes_written: usize,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// We write our analysis results to a file immediately to minimize the amount of
|
// We write our analysis results to a file immediately to minimize the amount of
|
||||||
@@ -35,15 +33,11 @@ pub struct AnalysisWriter {
|
|||||||
// lets us simply append new rows to the end without parsing the entire JSON
|
// lets us simply append new rows to the end without parsing the entire JSON
|
||||||
// object beforehand.
|
// object beforehand.
|
||||||
impl AnalysisWriter {
|
impl AnalysisWriter {
|
||||||
pub async fn new(file: File, enable_dummy_analyzer: bool) -> Result<Self, std::io::Error> {
|
pub async fn new(file: File, analyzer_config: &AnalyzerConfig) -> Result<Self, std::io::Error> {
|
||||||
let mut harness = Harness::new_with_all_analyzers();
|
let harness = Harness::new_with_config(analyzer_config);
|
||||||
if enable_dummy_analyzer {
|
|
||||||
harness.add_analyzer(Box::new(TestAnalyzer { count: 0 }));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut result = Self {
|
let mut result = Self {
|
||||||
writer: BufWriter::new(file),
|
writer: BufWriter::new(file),
|
||||||
bytes_written: 0,
|
|
||||||
harness,
|
harness,
|
||||||
};
|
};
|
||||||
let metadata = result.harness.get_metadata();
|
let metadata = result.harness.get_metadata();
|
||||||
@@ -52,22 +46,25 @@ impl AnalysisWriter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Runs the analysis harness on the given container, serializing the results
|
// Runs the analysis harness on the given container, serializing the results
|
||||||
// to the analysis file and returning the file's new length.
|
// to the analysis file, returning the whether any warnings were detected
|
||||||
pub async fn analyze(
|
pub async fn analyze(
|
||||||
&mut self,
|
&mut self,
|
||||||
container: MessagesContainer,
|
container: MessagesContainer,
|
||||||
) -> Result<(usize, bool), std::io::Error> {
|
) -> Result<EventType, std::io::Error> {
|
||||||
let row = self.harness.analyze_qmdl_messages(container);
|
let mut max_type = EventType::Informational;
|
||||||
if !row.is_empty() {
|
|
||||||
self.write(&row).await?;
|
for row in self.harness.analyze_qmdl_messages(container) {
|
||||||
|
if !row.is_empty() {
|
||||||
|
self.write(&row).await?;
|
||||||
|
}
|
||||||
|
max_type = cmp::max(max_type, row.get_max_event_type());
|
||||||
}
|
}
|
||||||
Ok((self.bytes_written, row.contains_warnings()))
|
Ok(max_type)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn write<T: Serialize>(&mut self, value: &T) -> Result<(), std::io::Error> {
|
async fn write<T: Serialize>(&mut self, value: &T) -> Result<(), std::io::Error> {
|
||||||
let mut value_str = serde_json::to_string(value).unwrap();
|
let mut value_str = serde_json::to_string(value).unwrap();
|
||||||
value_str.push('\n');
|
value_str.push('\n');
|
||||||
self.bytes_written += value_str.len();
|
|
||||||
self.writer.write_all(value_str.as_bytes()).await?;
|
self.writer.write_all(value_str.as_bytes()).await?;
|
||||||
self.writer.flush().await?;
|
self.writer.flush().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -130,62 +127,58 @@ async fn finish_running_analysis(analysis_status_lock: Arc<RwLock<AnalysisStatus
|
|||||||
async fn perform_analysis(
|
async fn perform_analysis(
|
||||||
name: &str,
|
name: &str,
|
||||||
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
|
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
|
||||||
enable_dummy_analyzer: bool,
|
analyzer_config: &AnalyzerConfig,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
info!("Opening QMDL and analysis file for {}...", name);
|
info!("Opening QMDL and analysis file for {name}...");
|
||||||
let (analysis_file, qmdl_file, entry_index) = {
|
let (analysis_file, qmdl_file) = {
|
||||||
let mut qmdl_store = qmdl_store_lock.write().await;
|
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||||
let (entry_index, _) = qmdl_store
|
let (entry_index, _) = qmdl_store
|
||||||
.entry_for_name(name)
|
.entry_for_name(name)
|
||||||
.ok_or(format!("failed to find QMDL store entry for {}", name))?;
|
.ok_or(format!("failed to find QMDL store entry for {name}"))?;
|
||||||
let analysis_file = qmdl_store
|
let analysis_file = qmdl_store
|
||||||
.clear_and_open_entry_analysis(entry_index)
|
.clear_and_open_entry_analysis(entry_index)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("{:?}", e))?;
|
.map_err(|e| format!("{e:?}"))?;
|
||||||
let qmdl_file = qmdl_store
|
let qmdl_file = qmdl_store
|
||||||
.open_entry_qmdl(entry_index)
|
.open_entry_qmdl(entry_index)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("{:?}", e))?;
|
.map_err(|e| format!("{e:?}"))?;
|
||||||
|
|
||||||
(analysis_file, qmdl_file, entry_index)
|
(analysis_file, qmdl_file)
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut analysis_writer = AnalysisWriter::new(analysis_file, enable_dummy_analyzer)
|
let mut analysis_writer = AnalysisWriter::new(analysis_file, analyzer_config)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("{:?}", e))?;
|
.map_err(|e| format!("{e:?}"))?;
|
||||||
let file_size = qmdl_file
|
let file_size = qmdl_file
|
||||||
.metadata()
|
.metadata()
|
||||||
.await
|
.await
|
||||||
.expect("failed to get QMDL file metadata")
|
.expect("failed to get QMDL file metadata")
|
||||||
.len();
|
.len();
|
||||||
let mut qmdl_reader = QmdlReader::new(qmdl_file, Some(file_size as usize));
|
let mut qmdl_reader = QmdlReader::new(qmdl_file, Some(file_size as usize));
|
||||||
let mut qmdl_stream = pin::pin!(qmdl_reader
|
let mut qmdl_stream = pin::pin!(
|
||||||
.as_stream()
|
qmdl_reader
|
||||||
.try_filter(|container| future::ready(container.data_type == DataType::UserSpace)));
|
.as_stream()
|
||||||
|
.try_filter(|container| future::ready(container.data_type == DataType::UserSpace))
|
||||||
|
);
|
||||||
|
|
||||||
info!("Starting analysis for {}...", name);
|
info!("Starting analysis for {name}...");
|
||||||
while let Some(container) = qmdl_stream
|
while let Some(container) = qmdl_stream
|
||||||
.try_next()
|
.try_next()
|
||||||
.await
|
.await
|
||||||
.expect("failed getting QMDL container")
|
.expect("failed getting QMDL container")
|
||||||
{
|
{
|
||||||
let (size_bytes, _) = analysis_writer
|
let _ = analysis_writer
|
||||||
.analyze(container)
|
.analyze(container)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("{:?}", e))?;
|
.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
|
analysis_writer
|
||||||
.close()
|
.close()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("{:?}", e))?;
|
.map_err(|e| format!("{e:?}"))?;
|
||||||
info!("Analysis for {} complete!", name);
|
info!("Analysis for {name} complete!");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -195,7 +188,7 @@ pub fn run_analysis_thread(
|
|||||||
mut analysis_rx: Receiver<AnalysisCtrlMessage>,
|
mut analysis_rx: Receiver<AnalysisCtrlMessage>,
|
||||||
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
|
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
|
||||||
analysis_status_lock: Arc<RwLock<AnalysisStatus>>,
|
analysis_status_lock: Arc<RwLock<AnalysisStatus>>,
|
||||||
enable_dummy_analyzer: bool,
|
analyzer_config: AnalyzerConfig,
|
||||||
) {
|
) {
|
||||||
task_tracker.spawn(async move {
|
task_tracker.spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
@@ -205,10 +198,9 @@ pub fn run_analysis_thread(
|
|||||||
for _ in 0..count {
|
for _ in 0..count {
|
||||||
let name = dequeue_to_running(analysis_status_lock.clone()).await;
|
let name = dequeue_to_running(analysis_status_lock.clone()).await;
|
||||||
if let Err(err) =
|
if let Err(err) =
|
||||||
perform_analysis(&name, qmdl_store_lock.clone(), enable_dummy_analyzer)
|
perform_analysis(&name, qmdl_store_lock.clone(), &analyzer_config).await
|
||||||
.await
|
|
||||||
{
|
{
|
||||||
error!("failed to analyze {}: {}", name, err);
|
error!("failed to analyze {name}: {err}");
|
||||||
}
|
}
|
||||||
finish_running_analysis(analysis_status_lock.clone()).await;
|
finish_running_analysis(analysis_status_lock.clone()).await;
|
||||||
}
|
}
|
||||||
@@ -269,7 +261,7 @@ pub async fn start_analysis(
|
|||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
(
|
(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
format!("failed to queue new analysis files: {:?}", e),
|
format!("failed to queue new analysis files: {e:?}"),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
47
daemon/src/battery/mod.rs
Normal file
47
daemon/src/battery/mod.rs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use rayhunter::Device;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::error::RayhunterError;
|
||||||
|
|
||||||
|
pub mod orbic;
|
||||||
|
pub mod tmobile;
|
||||||
|
pub mod wingtech;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Debug, Serialize)]
|
||||||
|
pub struct BatteryState {
|
||||||
|
level: u8,
|
||||||
|
is_plugged_in: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn is_plugged_in_from_file(path: &Path) -> Result<bool, RayhunterError> {
|
||||||
|
match tokio::fs::read_to_string(path)
|
||||||
|
.await
|
||||||
|
.map_err(RayhunterError::TokioError)?
|
||||||
|
.chars()
|
||||||
|
.next()
|
||||||
|
{
|
||||||
|
Some('0') => Ok(false),
|
||||||
|
Some('1') => Ok(true),
|
||||||
|
_ => Err(RayhunterError::BatteryPluggedInStatusParseError),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_level_from_percentage_file(path: &Path) -> Result<u8, RayhunterError> {
|
||||||
|
tokio::fs::read_to_string(path)
|
||||||
|
.await
|
||||||
|
.map_err(RayhunterError::TokioError)?
|
||||||
|
.trim_end()
|
||||||
|
.parse()
|
||||||
|
.or(Err(RayhunterError::BatteryLevelParseError))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_battery_status(device: &Device) -> Result<BatteryState, RayhunterError> {
|
||||||
|
Ok(match device {
|
||||||
|
Device::Orbic => orbic::get_battery_state().await?,
|
||||||
|
Device::Wingtech => wingtech::get_battery_state().await?,
|
||||||
|
Device::Tmobile => tmobile::get_battery_state().await?,
|
||||||
|
_ => return Err(RayhunterError::FunctionNotSupportedForDeviceError),
|
||||||
|
})
|
||||||
|
}
|
||||||
28
daemon/src/battery/orbic.rs
Normal file
28
daemon/src/battery/orbic.rs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
battery::{BatteryState, is_plugged_in_from_file},
|
||||||
|
error::RayhunterError,
|
||||||
|
};
|
||||||
|
|
||||||
|
const BATTERY_LEVEL_FILE: &str = "/sys/kernel/chg_info/level";
|
||||||
|
const PLUGGED_IN_STATE_FILE: &str = "/sys/kernel/chg_info/chg_en";
|
||||||
|
|
||||||
|
pub async fn get_battery_state() -> Result<BatteryState, RayhunterError> {
|
||||||
|
Ok(BatteryState {
|
||||||
|
level: match tokio::fs::read_to_string(&BATTERY_LEVEL_FILE)
|
||||||
|
.await
|
||||||
|
.map_err(RayhunterError::TokioError)?
|
||||||
|
.chars()
|
||||||
|
.next()
|
||||||
|
{
|
||||||
|
Some('1') => Ok(10),
|
||||||
|
Some('2') => Ok(25),
|
||||||
|
Some('3') => Ok(50),
|
||||||
|
Some('4') => Ok(75),
|
||||||
|
Some('5') => Ok(100),
|
||||||
|
_ => Err(RayhunterError::BatteryLevelParseError),
|
||||||
|
}?,
|
||||||
|
is_plugged_in: is_plugged_in_from_file(Path::new(PLUGGED_IN_STATE_FILE)).await?,
|
||||||
|
})
|
||||||
|
}
|
||||||
16
daemon/src/battery/tmobile.rs
Normal file
16
daemon/src/battery/tmobile.rs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
battery::{BatteryState, get_level_from_percentage_file, is_plugged_in_from_file},
|
||||||
|
error::RayhunterError,
|
||||||
|
};
|
||||||
|
|
||||||
|
const BATTERY_LEVEL_FILE: &str = "/sys/class/power_supply/bms/capacity";
|
||||||
|
const PLUGGED_IN_STATE_FILE: &str = "/sys/devices/78d9000.usb/power_supply/usb/online";
|
||||||
|
|
||||||
|
pub async fn get_battery_state() -> Result<BatteryState, RayhunterError> {
|
||||||
|
Ok(BatteryState {
|
||||||
|
level: get_level_from_percentage_file(Path::new(BATTERY_LEVEL_FILE)).await?,
|
||||||
|
is_plugged_in: is_plugged_in_from_file(Path::new(PLUGGED_IN_STATE_FILE)).await?,
|
||||||
|
})
|
||||||
|
}
|
||||||
17
daemon/src/battery/wingtech.rs
Normal file
17
daemon/src/battery/wingtech.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
battery::{BatteryState, get_level_from_percentage_file, is_plugged_in_from_file},
|
||||||
|
error::RayhunterError,
|
||||||
|
};
|
||||||
|
|
||||||
|
const BATTERY_LEVEL_FILE: &str =
|
||||||
|
"/sys/devices/78b7000.i2c/i2c-3/3-0063/power_supply/cw2017-bat/capacity";
|
||||||
|
const PLUGGED_IN_STATE_FILE: &str = "/sys/devices/8a00000.ssusb/power_supply/usb/online";
|
||||||
|
|
||||||
|
pub async fn get_battery_state() -> Result<BatteryState, RayhunterError> {
|
||||||
|
Ok(BatteryState {
|
||||||
|
level: get_level_from_percentage_file(Path::new(BATTERY_LEVEL_FILE)).await?,
|
||||||
|
is_plugged_in: is_plugged_in_from_file(Path::new(PLUGGED_IN_STATE_FILE)).await?,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,16 +1,23 @@
|
|||||||
|
use log::warn;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use rayhunter::Device;
|
||||||
|
use rayhunter::analysis::analyzer::AnalyzerConfig;
|
||||||
|
|
||||||
use crate::error::RayhunterError;
|
use crate::error::RayhunterError;
|
||||||
|
|
||||||
use serde::Deserialize;
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub qmdl_store_path: String,
|
pub qmdl_store_path: String,
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
pub debug_mode: bool,
|
pub debug_mode: bool,
|
||||||
|
pub device: Device,
|
||||||
pub ui_level: u8,
|
pub ui_level: u8,
|
||||||
pub enable_dummy_analyzer: bool,
|
|
||||||
pub colorblind_mode: bool,
|
pub colorblind_mode: bool,
|
||||||
|
pub key_input_mode: u8,
|
||||||
|
pub ntfy_url: Option<String>,
|
||||||
|
pub analyzers: AnalyzerConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
@@ -19,20 +26,24 @@ impl Default for Config {
|
|||||||
qmdl_store_path: "/data/rayhunter/qmdl".to_string(),
|
qmdl_store_path: "/data/rayhunter/qmdl".to_string(),
|
||||||
port: 8080,
|
port: 8080,
|
||||||
debug_mode: false,
|
debug_mode: false,
|
||||||
|
device: Device::Orbic,
|
||||||
ui_level: 1,
|
ui_level: 1,
|
||||||
enable_dummy_analyzer: false,
|
|
||||||
colorblind_mode: false,
|
colorblind_mode: false,
|
||||||
|
key_input_mode: 0,
|
||||||
|
analyzers: AnalyzerConfig::default(),
|
||||||
|
ntfy_url: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_config<P>(path: P) -> Result<Config, RayhunterError>
|
pub async fn parse_config<P>(path: P) -> Result<Config, RayhunterError>
|
||||||
where
|
where
|
||||||
P: AsRef<std::path::Path>,
|
P: AsRef<std::path::Path>,
|
||||||
{
|
{
|
||||||
if let Ok(config_file) = std::fs::read_to_string(&path) {
|
if let Ok(config_file) = tokio::fs::read_to_string(&path).await {
|
||||||
Ok(toml::from_str(&config_file).map_err(RayhunterError::ConfigFileParsingError)?)
|
Ok(toml::from_str(&config_file).map_err(RayhunterError::ConfigFileParsingError)?)
|
||||||
} else {
|
} else {
|
||||||
|
warn!("unable to read config file, using default config");
|
||||||
Ok(Config::default())
|
Ok(Config::default())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
451
daemon/src/diag.rs
Normal file
451
daemon/src/diag.rs
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
use std::ops::DerefMut;
|
||||||
|
use std::pin::pin;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::http::header::CONTENT_TYPE;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use futures::{StreamExt, TryStreamExt, future};
|
||||||
|
use log::{debug, error, info, warn};
|
||||||
|
use tokio::fs::File;
|
||||||
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
|
use tokio::sync::mpsc::{Receiver, Sender};
|
||||||
|
use tokio::sync::{RwLock, oneshot};
|
||||||
|
use tokio_stream::wrappers::LinesStream;
|
||||||
|
use tokio_util::task::TaskTracker;
|
||||||
|
|
||||||
|
use rayhunter::analysis::analyzer::{AnalysisLineNormalizer, AnalyzerConfig, EventType};
|
||||||
|
use rayhunter::diag::{DataType, MessagesContainer};
|
||||||
|
use rayhunter::diag_device::DiagDevice;
|
||||||
|
use rayhunter::qmdl::QmdlWriter;
|
||||||
|
|
||||||
|
use crate::analysis::{AnalysisCtrlMessage, AnalysisWriter};
|
||||||
|
use crate::display;
|
||||||
|
use crate::notifications::Notification;
|
||||||
|
use crate::qmdl_store::{RecordingStore, RecordingStoreError};
|
||||||
|
use crate::server::ServerState;
|
||||||
|
|
||||||
|
pub enum DiagDeviceCtrlMessage {
|
||||||
|
StopRecording,
|
||||||
|
StartRecording,
|
||||||
|
DeleteEntry {
|
||||||
|
name: String,
|
||||||
|
response_tx: oneshot::Sender<Result<(), RecordingStoreError>>,
|
||||||
|
},
|
||||||
|
DeleteAllEntries {
|
||||||
|
response_tx: oneshot::Sender<Result<(), RecordingStoreError>>,
|
||||||
|
},
|
||||||
|
Exit,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DiagTask {
|
||||||
|
ui_update_sender: Sender<display::DisplayState>,
|
||||||
|
analysis_sender: Sender<AnalysisCtrlMessage>,
|
||||||
|
analyzer_config: AnalyzerConfig,
|
||||||
|
notification_channel: tokio::sync::mpsc::Sender<Notification>,
|
||||||
|
state: DiagState,
|
||||||
|
max_type_seen: EventType,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DiagState {
|
||||||
|
Recording {
|
||||||
|
qmdl_writer: QmdlWriter<File>,
|
||||||
|
analysis_writer: Box<AnalysisWriter>,
|
||||||
|
},
|
||||||
|
Stopped,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiagTask {
|
||||||
|
fn new(
|
||||||
|
ui_update_sender: Sender<display::DisplayState>,
|
||||||
|
analysis_sender: Sender<AnalysisCtrlMessage>,
|
||||||
|
analyzer_config: AnalyzerConfig,
|
||||||
|
notification_channel: tokio::sync::mpsc::Sender<Notification>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
ui_update_sender,
|
||||||
|
analysis_sender,
|
||||||
|
analyzer_config,
|
||||||
|
notification_channel,
|
||||||
|
state: DiagState::Stopped,
|
||||||
|
max_type_seen: EventType::Informational,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start recording
|
||||||
|
async fn start(&mut self, qmdl_store: &mut RecordingStore) {
|
||||||
|
let (qmdl_file, analysis_file) = qmdl_store
|
||||||
|
.new_entry()
|
||||||
|
.await
|
||||||
|
.expect("failed creating QMDL file entry");
|
||||||
|
self.stop_current_recording().await;
|
||||||
|
let qmdl_writer = QmdlWriter::new(qmdl_file);
|
||||||
|
let analysis_writer = AnalysisWriter::new(analysis_file, &self.analyzer_config)
|
||||||
|
.await
|
||||||
|
.map(Box::new)
|
||||||
|
.expect("failed to write to analysis file");
|
||||||
|
self.state = DiagState::Recording {
|
||||||
|
qmdl_writer,
|
||||||
|
analysis_writer,
|
||||||
|
};
|
||||||
|
if let Err(e) = self
|
||||||
|
.ui_update_sender
|
||||||
|
.send(display::DisplayState::Recording)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
warn!("couldn't send ui update message: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop recording
|
||||||
|
async fn stop(&mut self, qmdl_store: &mut RecordingStore) {
|
||||||
|
self.stop_current_recording().await;
|
||||||
|
if let Some((_, entry)) = qmdl_store.get_current_entry()
|
||||||
|
&& let Err(e) = self
|
||||||
|
.analysis_sender
|
||||||
|
.send(AnalysisCtrlMessage::RecordingFinished(
|
||||||
|
entry.name.to_string(),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
warn!("couldn't send analysis message: {e}");
|
||||||
|
}
|
||||||
|
if let Err(e) = qmdl_store.close_current_entry().await {
|
||||||
|
error!("couldn't close current entry: {e}");
|
||||||
|
}
|
||||||
|
if let Err(e) = self
|
||||||
|
.ui_update_sender
|
||||||
|
.send(display::DisplayState::Paused)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
warn!("couldn't send ui update message: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_entry(
|
||||||
|
&mut self,
|
||||||
|
qmdl_store: &mut RecordingStore,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<(), RecordingStoreError> {
|
||||||
|
if qmdl_store.is_current_entry(name) {
|
||||||
|
self.stop(qmdl_store).await;
|
||||||
|
}
|
||||||
|
let res = qmdl_store.delete_entry(name).await;
|
||||||
|
if let Err(e) = res.as_ref() {
|
||||||
|
error!("Error deleting QMDL entry {e}");
|
||||||
|
}
|
||||||
|
res
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_all_entries(
|
||||||
|
&mut self,
|
||||||
|
qmdl_store: &mut RecordingStore,
|
||||||
|
) -> Result<(), RecordingStoreError> {
|
||||||
|
self.stop(qmdl_store).await;
|
||||||
|
let res = qmdl_store.delete_all_entries().await;
|
||||||
|
if let Err(e) = res.as_ref() {
|
||||||
|
error!("Error deleting QMDL entries {e}");
|
||||||
|
}
|
||||||
|
res
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stop_current_recording(&mut self) {
|
||||||
|
let mut state = DiagState::Stopped;
|
||||||
|
std::mem::swap(&mut self.state, &mut state);
|
||||||
|
if let DiagState::Recording {
|
||||||
|
analysis_writer, ..
|
||||||
|
} = state
|
||||||
|
{
|
||||||
|
analysis_writer
|
||||||
|
.close()
|
||||||
|
.await
|
||||||
|
.expect("failed to close analysis writer");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn process_container(
|
||||||
|
&mut self,
|
||||||
|
qmdl_store: &mut RecordingStore,
|
||||||
|
container: MessagesContainer,
|
||||||
|
) {
|
||||||
|
if container.data_type != DataType::UserSpace {
|
||||||
|
debug!("skipping non-userspace diag messages...");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// keep track of how many bytes were written to the QMDL file so we can read
|
||||||
|
// a valid block of data from it in the HTTP server
|
||||||
|
if let DiagState::Recording {
|
||||||
|
qmdl_writer,
|
||||||
|
analysis_writer,
|
||||||
|
} = &mut self.state
|
||||||
|
{
|
||||||
|
qmdl_writer
|
||||||
|
.write_container(&container)
|
||||||
|
.await
|
||||||
|
.expect("failed to write to QMDL writer");
|
||||||
|
debug!(
|
||||||
|
"total QMDL bytes written: {}, updating manifest...",
|
||||||
|
qmdl_writer.total_written
|
||||||
|
);
|
||||||
|
let index = qmdl_store
|
||||||
|
.current_entry
|
||||||
|
.expect("DiagDevice had qmdl_writer, but QmdlStore didn't have current entry???");
|
||||||
|
qmdl_store
|
||||||
|
.update_entry_qmdl_size(index, qmdl_writer.total_written)
|
||||||
|
.await
|
||||||
|
.expect("failed to update qmdl file size");
|
||||||
|
debug!("done!");
|
||||||
|
let max_type = analysis_writer
|
||||||
|
.analyze(container)
|
||||||
|
.await
|
||||||
|
.expect("failed to analyze container");
|
||||||
|
|
||||||
|
if max_type > EventType::Informational {
|
||||||
|
info!("a heuristic triggered on this run!");
|
||||||
|
self.notification_channel
|
||||||
|
.send(Notification::new(
|
||||||
|
"heuristic-warning".to_string(),
|
||||||
|
format!("Rayhunter has detected a {:?} severity event", max_type),
|
||||||
|
Some(Duration::from_secs(60 * 5)),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.expect("Failed to send to notification channel");
|
||||||
|
}
|
||||||
|
|
||||||
|
if max_type > self.max_type_seen {
|
||||||
|
self.max_type_seen = max_type;
|
||||||
|
if self.max_type_seen > EventType::Informational {
|
||||||
|
self.ui_update_sender
|
||||||
|
.send(display::DisplayState::WarningDetected {
|
||||||
|
event_type: self.max_type_seen,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("couldn't send ui update message: {}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
debug!("no qmdl_writer set, continuing...");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn run_diag_read_thread(
|
||||||
|
task_tracker: &TaskTracker,
|
||||||
|
mut dev: DiagDevice,
|
||||||
|
mut qmdl_file_rx: Receiver<DiagDeviceCtrlMessage>,
|
||||||
|
qmdl_file_tx: Sender<DiagDeviceCtrlMessage>,
|
||||||
|
ui_update_sender: Sender<display::DisplayState>,
|
||||||
|
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
|
||||||
|
analysis_sender: Sender<AnalysisCtrlMessage>,
|
||||||
|
analyzer_config: AnalyzerConfig,
|
||||||
|
notification_channel: tokio::sync::mpsc::Sender<Notification>,
|
||||||
|
) {
|
||||||
|
task_tracker.spawn(async move {
|
||||||
|
let mut diag_stream = pin!(dev.as_stream().into_stream());
|
||||||
|
let mut diag_task = DiagTask::new(ui_update_sender, analysis_sender, analyzer_config, notification_channel);
|
||||||
|
qmdl_file_tx
|
||||||
|
.send(DiagDeviceCtrlMessage::StartRecording)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
msg = qmdl_file_rx.recv() => {
|
||||||
|
match msg {
|
||||||
|
Some(DiagDeviceCtrlMessage::StartRecording) => {
|
||||||
|
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||||
|
diag_task.start(qmdl_store.deref_mut()).await;
|
||||||
|
},
|
||||||
|
Some(DiagDeviceCtrlMessage::StopRecording) => {
|
||||||
|
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||||
|
diag_task.stop(qmdl_store.deref_mut()).await;
|
||||||
|
},
|
||||||
|
// None means all the Senders have been dropped, so it's
|
||||||
|
// time to go
|
||||||
|
Some(DiagDeviceCtrlMessage::Exit) | None => {
|
||||||
|
info!("Diag reader thread exiting...");
|
||||||
|
diag_task.stop_current_recording().await;
|
||||||
|
return Ok(())
|
||||||
|
},
|
||||||
|
Some(DiagDeviceCtrlMessage::DeleteEntry { name, response_tx }) => {
|
||||||
|
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||||
|
let resp = diag_task.delete_entry(qmdl_store.deref_mut(), name.as_str()).await;
|
||||||
|
if response_tx.send(resp).is_err() {
|
||||||
|
error!("Failed to send delete entry respons, receiver dropped");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Some(DiagDeviceCtrlMessage::DeleteAllEntries { response_tx }) => {
|
||||||
|
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||||
|
let resp = diag_task.delete_all_entries(qmdl_store.deref_mut()).await;
|
||||||
|
if response_tx.send(resp).is_err() {
|
||||||
|
error!("Failed to send delete all entries respons, receiver dropped");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
maybe_container = diag_stream.next() => {
|
||||||
|
match maybe_container.unwrap() {
|
||||||
|
Ok(container) => {
|
||||||
|
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||||
|
diag_task.process_container(qmdl_store.deref_mut(), container).await
|
||||||
|
},
|
||||||
|
Err(err) => {
|
||||||
|
error!("error reading diag device: {err}");
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start recording API for web thread
|
||||||
|
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()));
|
||||||
|
}
|
||||||
|
|
||||||
|
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()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop recording API for web thread
|
||||||
|
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()));
|
||||||
|
}
|
||||||
|
state
|
||||||
|
.diag_device_ctrl_sender
|
||||||
|
.send(DiagDeviceCtrlMessage::StopRecording)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("couldn't send stop recording message: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok((StatusCode::ACCEPTED, "ok".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_recording(
|
||||||
|
State(state): State<Arc<ServerState>>,
|
||||||
|
Path(qmdl_name): Path<String>,
|
||||||
|
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||||
|
if state.config.debug_mode {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
|
||||||
|
}
|
||||||
|
let (response_tx, response_rx) = oneshot::channel();
|
||||||
|
state
|
||||||
|
.diag_device_ctrl_sender
|
||||||
|
.send(DiagDeviceCtrlMessage::DeleteEntry {
|
||||||
|
name: qmdl_name.clone(),
|
||||||
|
response_tx,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("couldn't send delete entry message: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
match response_rx.await.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("failed to receive delete response: {e}"),
|
||||||
|
)
|
||||||
|
})? {
|
||||||
|
Ok(_) => Ok((StatusCode::ACCEPTED, "ok".to_string())),
|
||||||
|
Err(RecordingStoreError::NoSuchEntryError) => Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
format!("no recording with name {qmdl_name}"),
|
||||||
|
)),
|
||||||
|
Err(e) => Err((
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("couldn't delete recording: {e}"),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_all_recordings(
|
||||||
|
State(state): State<Arc<ServerState>>,
|
||||||
|
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||||
|
if state.config.debug_mode {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
|
||||||
|
}
|
||||||
|
let (response_tx, response_rx) = oneshot::channel();
|
||||||
|
state
|
||||||
|
.diag_device_ctrl_sender
|
||||||
|
.send(DiagDeviceCtrlMessage::DeleteAllEntries { response_tx })
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("couldn't send delete all entries message: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
match response_rx.await.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("failed to receive delete all response: {e}"),
|
||||||
|
)
|
||||||
|
})? {
|
||||||
|
Ok(_) => Ok((StatusCode::ACCEPTED, "ok".to_string())),
|
||||||
|
Err(e) => Err((
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("couldn't delete recordings: {e}"),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_analysis_report(
|
||||||
|
State(state): State<Arc<ServerState>>,
|
||||||
|
Path(qmdl_name): Path<String>,
|
||||||
|
) -> Result<Response, (StatusCode, String)> {
|
||||||
|
let qmdl_store = state.qmdl_store_lock.read().await;
|
||||||
|
let (entry_index, _) = if qmdl_name == "live" {
|
||||||
|
qmdl_store.get_current_entry().ok_or((
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"No QMDL data's being recorded to analyze, try starting a new recording!".to_string(),
|
||||||
|
))?
|
||||||
|
} 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_index)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?;
|
||||||
|
|
||||||
|
// Read and normalize the NDJSON file
|
||||||
|
let reader = BufReader::new(analysis_file);
|
||||||
|
let lines_stream = LinesStream::new(reader.lines());
|
||||||
|
|
||||||
|
let mut normalizer = AnalysisLineNormalizer::new();
|
||||||
|
let normalized_stream = lines_stream
|
||||||
|
.try_filter(|line| future::ready(!line.is_empty()))
|
||||||
|
.map_ok(move |line| normalizer.normalize_line(line));
|
||||||
|
|
||||||
|
let headers = [(CONTENT_TYPE, "application/x-ndjson")];
|
||||||
|
let body = Body::from_stream(normalized_stream);
|
||||||
|
Ok((headers, body).into_response())
|
||||||
|
}
|
||||||
242
daemon/src/display/generic_framebuffer.rs
Normal file
242
daemon/src/display/generic_framebuffer.rs
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use image::{AnimationDecoder, DynamicImage, codecs::gif::GifDecoder, imageops::FilterType};
|
||||||
|
use std::io::Cursor;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::config;
|
||||||
|
use crate::display::DisplayState;
|
||||||
|
use rayhunter::analysis::analyzer::EventType;
|
||||||
|
|
||||||
|
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 include_dir::{Dir, include_dir};
|
||||||
|
|
||||||
|
const REFRESH_RATE: u64 = 1000; //how often in milliseconds to refresh the display
|
||||||
|
|
||||||
|
#[derive(Copy, Clone)]
|
||||||
|
pub struct Dimensions {
|
||||||
|
pub height: u32,
|
||||||
|
pub width: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone)]
|
||||||
|
pub enum LinePattern {
|
||||||
|
Solid,
|
||||||
|
Dashed, // _ _ _ _
|
||||||
|
Dotted, // . . . .
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Copy, Clone)]
|
||||||
|
pub enum Color {
|
||||||
|
Red,
|
||||||
|
Green,
|
||||||
|
Blue,
|
||||||
|
White,
|
||||||
|
Black,
|
||||||
|
Cyan,
|
||||||
|
Yellow,
|
||||||
|
Pink,
|
||||||
|
Orange,
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
Color::Orange => (0xff, 0xa5, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn display_style_from_state(state: DisplayState, colorblind_mode: bool) -> (Color, LinePattern) {
|
||||||
|
match state {
|
||||||
|
DisplayState::Paused => (Color::White, LinePattern::Solid),
|
||||||
|
DisplayState::Recording => {
|
||||||
|
if colorblind_mode {
|
||||||
|
(Color::Blue, LinePattern::Solid)
|
||||||
|
} else {
|
||||||
|
(Color::Green, LinePattern::Solid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DisplayState::WarningDetected { event_type } => match event_type {
|
||||||
|
EventType::Informational => {
|
||||||
|
if colorblind_mode {
|
||||||
|
(Color::Blue, LinePattern::Solid)
|
||||||
|
} else {
|
||||||
|
(Color::Green, LinePattern::Solid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EventType::Low => (Color::Yellow, LinePattern::Dotted),
|
||||||
|
EventType::Medium => (Color::Orange, LinePattern::Dashed),
|
||||||
|
EventType::High => (Color::Red, LinePattern::Solid),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait GenericFramebuffer: Send + 'static {
|
||||||
|
fn dimensions(&self) -> Dimensions;
|
||||||
|
|
||||||
|
async fn write_buffer(&mut self, buffer: Vec<(u8, u8, u8)>); // rgb, row-wise, left-to-right, top-to-bottom
|
||||||
|
|
||||||
|
async 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).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn draw_gif(&mut self, img_buffer: &[u8]) {
|
||||||
|
let cursor = Cursor::new(img_buffer);
|
||||||
|
if let Ok(decoder) = GifDecoder::new(cursor) {
|
||||||
|
let frames: Vec<_> = decoder
|
||||||
|
.into_frames()
|
||||||
|
.filter_map(|f| f.ok())
|
||||||
|
.map(|frame| {
|
||||||
|
let (numerator, _) = frame.delay().numer_denom_ms();
|
||||||
|
let img = DynamicImage::from(frame.into_buffer());
|
||||||
|
(img, numerator as u64)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (img, delay_ms) in frames {
|
||||||
|
self.write_dynamic_image(img).await;
|
||||||
|
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn draw_img(&mut self, img_buffer: &[u8]) {
|
||||||
|
let img = image::load_from_memory(img_buffer).unwrap();
|
||||||
|
self.write_dynamic_image(img).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn draw_line(&mut self, color: Color, height: u32) {
|
||||||
|
self.draw_patterned_line(color, height, LinePattern::Solid)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn draw_patterned_line(&mut self, color: Color, height: u32, pattern: LinePattern) {
|
||||||
|
let width = self.dimensions().width;
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
for _row in 0..height {
|
||||||
|
for col in 0..width {
|
||||||
|
let should_draw = match pattern {
|
||||||
|
LinePattern::Solid => true,
|
||||||
|
LinePattern::Dashed => (col / 4) % 2 == 0, // 4 pixels on, 4 pixels off
|
||||||
|
LinePattern::Dotted => col % 4 == 0, // 1 pixel on, 3 pixels off
|
||||||
|
};
|
||||||
|
|
||||||
|
if should_draw {
|
||||||
|
buffer.push(color.rgb());
|
||||||
|
} else {
|
||||||
|
buffer.push((0, 0, 0)); // Black background
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.write_buffer(buffer).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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_style = display_style_from_state(DisplayState::Recording, colorblind_mode);
|
||||||
|
|
||||||
|
task_tracker.spawn(async 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_style = display_style_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()).await,
|
||||||
|
3 => fb.draw_img(img.unwrap()).await,
|
||||||
|
128 => {
|
||||||
|
fb.draw_line(Color::Cyan, 128).await;
|
||||||
|
fb.draw_line(Color::Pink, 102).await;
|
||||||
|
fb.draw_line(Color::White, 76).await;
|
||||||
|
fb.draw_line(Color::Pink, 50).await;
|
||||||
|
fb.draw_line(Color::Cyan, 25).await;
|
||||||
|
}
|
||||||
|
// this branch id for ui_level 1, which is also the default if an
|
||||||
|
// unknown value is used
|
||||||
|
_ => {}
|
||||||
|
};
|
||||||
|
let (color, pattern) = display_style;
|
||||||
|
fb.draw_patterned_line(color, 2, pattern).await;
|
||||||
|
tokio::time::sleep(Duration::from_millis(REFRESH_RATE)).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
16
daemon/src/display/headless.rs
Normal file
16
daemon/src/display/headless.rs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
use log::info;
|
||||||
|
use tokio::sync::mpsc::Receiver;
|
||||||
|
use tokio::sync::oneshot;
|
||||||
|
use tokio_util::task::TaskTracker;
|
||||||
|
|
||||||
|
use crate::config;
|
||||||
|
use crate::display::DisplayState;
|
||||||
|
|
||||||
|
pub fn update_ui(
|
||||||
|
_task_tracker: &TaskTracker,
|
||||||
|
_config: &config::Config,
|
||||||
|
_ui_shutdown_rx: oneshot::Receiver<()>,
|
||||||
|
_ui_update_rx: Receiver<DisplayState>,
|
||||||
|
) {
|
||||||
|
info!("Headless mode, not spawning UI.");
|
||||||
|
}
|
||||||
26
daemon/src/display/mod.rs
Normal file
26
daemon/src/display/mod.rs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
use rayhunter::analysis::analyzer::EventType;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
mod generic_framebuffer;
|
||||||
|
|
||||||
|
pub mod headless;
|
||||||
|
pub mod orbic;
|
||||||
|
pub mod tmobile;
|
||||||
|
pub mod tplink;
|
||||||
|
pub mod tplink_framebuffer;
|
||||||
|
pub mod tplink_onebit;
|
||||||
|
pub mod uz801;
|
||||||
|
pub mod wingtech;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub enum DisplayState {
|
||||||
|
/// We're recording but no warning has been found yet.
|
||||||
|
Recording,
|
||||||
|
/// We're not recording.
|
||||||
|
Paused,
|
||||||
|
/// A non-informational event has been detected.
|
||||||
|
///
|
||||||
|
/// Note that EventType::Informational is never sent through this. If it is, it's the same as
|
||||||
|
/// Recording
|
||||||
|
WarningDetected { event_type: EventType },
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::config;
|
use crate::config;
|
||||||
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
|
|
||||||
use crate::display::DisplayState;
|
use crate::display::DisplayState;
|
||||||
|
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
use tokio::sync::mpsc::Receiver;
|
use tokio::sync::mpsc::Receiver;
|
||||||
use tokio::sync::oneshot;
|
use tokio::sync::oneshot;
|
||||||
@@ -11,6 +12,7 @@ const FB_PATH: &str = "/dev/fb0";
|
|||||||
#[derive(Copy, Clone, Default)]
|
#[derive(Copy, Clone, Default)]
|
||||||
struct Framebuffer;
|
struct Framebuffer;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
impl GenericFramebuffer for Framebuffer {
|
impl GenericFramebuffer for Framebuffer {
|
||||||
fn dimensions(&self) -> Dimensions {
|
fn dimensions(&self) -> Dimensions {
|
||||||
// TODO actually poll for this, maybe w/ fbset?
|
// TODO actually poll for this, maybe w/ fbset?
|
||||||
@@ -20,16 +22,16 @@ impl GenericFramebuffer for Framebuffer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_buffer(&mut self, buffer: &[(u8, u8, u8)]) {
|
async fn write_buffer(&mut self, buffer: Vec<(u8, u8, u8)>) {
|
||||||
let mut raw_buffer = Vec::new();
|
let mut raw_buffer = Vec::new();
|
||||||
for (r, g, b) in buffer {
|
for (r, g, b) in buffer {
|
||||||
let mut rgb565: u16 = (*r as u16 & 0b11111000) << 8;
|
let mut rgb565: u16 = (r as u16 & 0b11111000) << 8;
|
||||||
rgb565 |= (*g as u16 & 0b11111100) << 3;
|
rgb565 |= (g as u16 & 0b11111100) << 3;
|
||||||
rgb565 |= (*b as u16) >> 3;
|
rgb565 |= (b as u16) >> 3;
|
||||||
raw_buffer.extend(rgb565.to_le_bytes());
|
raw_buffer.extend(rgb565.to_le_bytes());
|
||||||
}
|
}
|
||||||
|
|
||||||
std::fs::write(FB_PATH, &raw_buffer).unwrap();
|
tokio::fs::write(FB_PATH, &raw_buffer).await.unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
81
daemon/src/display/tmobile.rs
Normal file
81
daemon/src/display/tmobile.rs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/// Display module for Tmobile TMOHS1, blink LEDs on the front of the device.
|
||||||
|
/// DisplayState::Recording => Signal LED slowly blinks blue.
|
||||||
|
/// DisplayState::Paused => WiFi LED blinks white.
|
||||||
|
/// DisplayState::WarningDetected { .. } => Signal LED slowly blinks red.
|
||||||
|
use log::{error, info};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tokio::sync::oneshot;
|
||||||
|
use tokio_util::task::TaskTracker;
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::config;
|
||||||
|
use crate::display::DisplayState;
|
||||||
|
|
||||||
|
macro_rules! led {
|
||||||
|
($l:expr) => {{ format!("/sys/class/leds/led:{}/blink", $l) }};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_blinking(path: String) {
|
||||||
|
tokio::fs::write(&path, "1").await.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stop_blinking(path: String) {
|
||||||
|
tokio::fs::write(&path, "0").await.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_ui(
|
||||||
|
task_tracker: &TaskTracker,
|
||||||
|
config: &config::Config,
|
||||||
|
mut ui_shutdown_rx: oneshot::Receiver<()>,
|
||||||
|
mut ui_update_rx: mpsc::Receiver<DisplayState>,
|
||||||
|
) {
|
||||||
|
let mut invisible: bool = false;
|
||||||
|
if config.ui_level == 0 {
|
||||||
|
info!("Invisible mode, not spawning UI.");
|
||||||
|
invisible = true;
|
||||||
|
}
|
||||||
|
task_tracker.spawn(async move {
|
||||||
|
let mut state = DisplayState::Recording;
|
||||||
|
let mut last_state = DisplayState::Paused;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match ui_shutdown_rx.try_recv() {
|
||||||
|
Ok(_) => {
|
||||||
|
info!("received UI shutdown");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(oneshot::error::TryRecvError::Empty) => {}
|
||||||
|
Err(e) => panic!("error receiving shutdown message: {e}"),
|
||||||
|
}
|
||||||
|
match ui_update_rx.try_recv() {
|
||||||
|
Ok(new_state) => state = new_state,
|
||||||
|
Err(mpsc::error::TryRecvError::Empty) => {}
|
||||||
|
Err(e) => error!("error receiving ui update message: {e}"),
|
||||||
|
};
|
||||||
|
if invisible || state == last_state {
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match state {
|
||||||
|
DisplayState::Paused => {
|
||||||
|
stop_blinking(led!("signal_blue")).await;
|
||||||
|
stop_blinking(led!("signal_red")).await;
|
||||||
|
start_blinking(led!("wlan_white")).await;
|
||||||
|
}
|
||||||
|
DisplayState::Recording => {
|
||||||
|
stop_blinking(led!("wlan_white")).await;
|
||||||
|
stop_blinking(led!("signal_red")).await;
|
||||||
|
start_blinking(led!("signal_blue")).await;
|
||||||
|
}
|
||||||
|
DisplayState::WarningDetected { .. } => {
|
||||||
|
stop_blinking(led!("wlan_white")).await;
|
||||||
|
stop_blinking(led!("signal_blue")).await;
|
||||||
|
start_blinking(led!("signal_red")).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
last_state = state;
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ use tokio::sync::oneshot;
|
|||||||
use tokio_util::task::TaskTracker;
|
use tokio_util::task::TaskTracker;
|
||||||
|
|
||||||
use crate::config;
|
use crate::config;
|
||||||
use crate::display::{tplink_framebuffer, tplink_onebit, DisplayState};
|
use crate::display::{DisplayState, tplink_framebuffer, tplink_onebit};
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
@@ -19,6 +19,8 @@ pub fn update_ui(
|
|||||||
info!("Invisible mode, not spawning UI.");
|
info!("Invisible mode, not spawning UI.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Since this is a one-time check at startup, using sync is acceptable
|
||||||
|
// The alternative would be to make the entire initialization async
|
||||||
if fs::exists(tplink_onebit::OLED_PATH).unwrap_or_default() {
|
if fs::exists(tplink_onebit::OLED_PATH).unwrap_or_default() {
|
||||||
info!("detected one-bit display");
|
info!("detected one-bit display");
|
||||||
tplink_onebit::update_ui(task_tracker, config, ui_shutdown_rx, ui_update_rx)
|
tplink_onebit::update_ui(task_tracker, config, ui_shutdown_rx, ui_update_rx)
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
use std::fs::File;
|
use async_trait::async_trait;
|
||||||
use std::io::Write;
|
|
||||||
use std::os::fd::AsRawFd;
|
use std::os::fd::AsRawFd;
|
||||||
|
use tokio::fs::OpenOptions;
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
|
||||||
use crate::config;
|
use crate::config;
|
||||||
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
|
|
||||||
use crate::display::DisplayState;
|
use crate::display::DisplayState;
|
||||||
|
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
|
||||||
|
|
||||||
use tokio::sync::mpsc::Receiver;
|
use tokio::sync::mpsc::Receiver;
|
||||||
use tokio::sync::oneshot;
|
use tokio::sync::oneshot;
|
||||||
@@ -24,6 +25,7 @@ struct fb_fillrect {
|
|||||||
rop: u32,
|
rop: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
impl GenericFramebuffer for Framebuffer {
|
impl GenericFramebuffer for Framebuffer {
|
||||||
fn dimensions(&self) -> Dimensions {
|
fn dimensions(&self) -> Dimensions {
|
||||||
// TODO actually poll for this, maybe w/ fbset?
|
// TODO actually poll for this, maybe w/ fbset?
|
||||||
@@ -33,12 +35,12 @@ impl GenericFramebuffer for Framebuffer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_buffer(&mut self, buffer: &[(u8, u8, u8)]) {
|
async fn write_buffer(&mut self, buffer: Vec<(u8, u8, u8)>) {
|
||||||
// for how to write to the buffer, consult M7350v5_en_gpl/bootable/recovery/recovery_color_oled.c
|
// for how to write to the buffer, consult M7350v5_en_gpl/bootable/recovery/recovery_color_oled.c
|
||||||
let dimensions = self.dimensions();
|
let dimensions = self.dimensions();
|
||||||
let width = dimensions.width;
|
let width = dimensions.width;
|
||||||
let height = buffer.len() as u32 / width;
|
let height = buffer.len() as u32 / width;
|
||||||
let mut f = File::options().write(true).open(FB_PATH).unwrap();
|
let mut f = OpenOptions::new().write(true).open(FB_PATH).await.unwrap();
|
||||||
let mut arg = fb_fillrect {
|
let mut arg = fb_fillrect {
|
||||||
dx: 0,
|
dx: 0,
|
||||||
dy: 0,
|
dy: 0,
|
||||||
@@ -50,15 +52,16 @@ impl GenericFramebuffer for Framebuffer {
|
|||||||
|
|
||||||
let mut raw_buffer = Vec::new();
|
let mut raw_buffer = Vec::new();
|
||||||
for (r, g, b) in buffer {
|
for (r, g, b) in buffer {
|
||||||
let mut rgb565: u16 = (*r as u16 & 0b11111000) << 8;
|
let mut rgb565: u16 = (r as u16 & 0b11111000) << 8;
|
||||||
rgb565 |= (*g as u16 & 0b11111100) << 3;
|
rgb565 |= (g as u16 & 0b11111100) << 3;
|
||||||
rgb565 |= (*b as u16) >> 3;
|
rgb565 |= (b as u16) >> 3;
|
||||||
// note: big-endian!
|
// note: big-endian!
|
||||||
raw_buffer.extend(rgb565.to_be_bytes());
|
raw_buffer.extend(rgb565.to_be_bytes());
|
||||||
}
|
}
|
||||||
|
|
||||||
f.write_all(&raw_buffer).unwrap();
|
f.write_all(&raw_buffer).await.unwrap();
|
||||||
|
|
||||||
|
// ioctl is a synchronous operation, but it's fast enough that it shouldn't block
|
||||||
unsafe {
|
unsafe {
|
||||||
let res = libc::ioctl(
|
let res = libc::ioctl(
|
||||||
f.as_raw_fd(),
|
f.as_raw_fd(),
|
||||||
@@ -68,7 +71,7 @@ impl GenericFramebuffer for Framebuffer {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if res < 0 {
|
if res < 0 {
|
||||||
panic!("failed to send FBIORECT_DISPLAY ioctl, {}", res);
|
panic!("failed to send FBIORECT_DISPLAY ioctl, {res}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,8 +10,6 @@ use tokio::sync::oneshot;
|
|||||||
use tokio::sync::oneshot::error::TryRecvError;
|
use tokio::sync::oneshot::error::TryRecvError;
|
||||||
use tokio_util::task::TaskTracker;
|
use tokio_util::task::TaskTracker;
|
||||||
|
|
||||||
use std::fs;
|
|
||||||
use std::thread::sleep;
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
pub const OLED_PATH: &str = "/sys/class/display/oled/oled_buffer";
|
pub const OLED_PATH: &str = "/sys/class/display/oled/oled_buffer";
|
||||||
@@ -122,7 +120,7 @@ pub fn update_ui(
|
|||||||
info!("Invisible mode, not spawning UI.");
|
info!("Invisible mode, not spawning UI.");
|
||||||
}
|
}
|
||||||
|
|
||||||
task_tracker.spawn_blocking(move || {
|
task_tracker.spawn(async move {
|
||||||
let mut pixels = STATUS_SMILING;
|
let mut pixels = STATUS_SMILING;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@@ -138,7 +136,7 @@ pub fn update_ui(
|
|||||||
match ui_update_rx.try_recv() {
|
match ui_update_rx.try_recv() {
|
||||||
Ok(DisplayState::Paused) => pixels = STATUS_PAUSED,
|
Ok(DisplayState::Paused) => pixels = STATUS_PAUSED,
|
||||||
Ok(DisplayState::Recording) => pixels = STATUS_SMILING,
|
Ok(DisplayState::Recording) => pixels = STATUS_SMILING,
|
||||||
Ok(DisplayState::WarningDetected) => pixels = STATUS_WARNING,
|
Ok(DisplayState::WarningDetected { .. }) => pixels = STATUS_WARNING,
|
||||||
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
|
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("error receiving framebuffer update message: {e}");
|
error!("error receiving framebuffer update message: {e}");
|
||||||
@@ -147,13 +145,13 @@ pub fn update_ui(
|
|||||||
|
|
||||||
// we write the status every second because it may have been overwritten through menu
|
// we write the status every second because it may have been overwritten through menu
|
||||||
// navigation.
|
// navigation.
|
||||||
if display_level != 0 {
|
if display_level != 0
|
||||||
if let Err(e) = fs::write(OLED_PATH, &pixels) {
|
&& let Err(e) = tokio::fs::write(OLED_PATH, pixels).await
|
||||||
error!("failed to write to display: {e}");
|
{
|
||||||
}
|
error!("failed to write to display: {e}");
|
||||||
}
|
}
|
||||||
|
|
||||||
sleep(Duration::from_millis(1000));
|
tokio::time::sleep(Duration::from_millis(1000)).await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
89
daemon/src/display/uz801.rs
Normal file
89
daemon/src/display/uz801.rs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
/// Display module for Uz801, light LEDs on the front of the device.
|
||||||
|
/// DisplayState::Recording => Green LED is solid.
|
||||||
|
/// DisplayState::Paused => Signal LED is solid blue (wifi LED).
|
||||||
|
/// DisplayState::WarningDetected => Signal LED is solid red.
|
||||||
|
use log::{error, info};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tokio::sync::oneshot;
|
||||||
|
use tokio_util::task::TaskTracker;
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::config;
|
||||||
|
use crate::display::DisplayState;
|
||||||
|
|
||||||
|
macro_rules! led {
|
||||||
|
($l:expr) => {{ format!("/sys/class/leds/{}/brightness", $l) }};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn led_on(path: String) {
|
||||||
|
tokio::fs::write(&path, "1").await.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn led_off(path: String) {
|
||||||
|
tokio::fs::write(&path, "0").await.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_ui(
|
||||||
|
task_tracker: &TaskTracker,
|
||||||
|
config: &config::Config,
|
||||||
|
mut ui_shutdown_rx: oneshot::Receiver<()>,
|
||||||
|
mut ui_update_rx: mpsc::Receiver<DisplayState>,
|
||||||
|
) {
|
||||||
|
let mut invisible: bool = false;
|
||||||
|
if config.ui_level == 0 {
|
||||||
|
info!("Invisible mode, not spawning UI.");
|
||||||
|
invisible = true;
|
||||||
|
}
|
||||||
|
task_tracker.spawn(async move {
|
||||||
|
let mut state = DisplayState::Recording;
|
||||||
|
let mut last_state = DisplayState::Paused;
|
||||||
|
let mut last_update = std::time::Instant::now();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match ui_shutdown_rx.try_recv() {
|
||||||
|
Ok(_) => {
|
||||||
|
info!("received UI shutdown");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(oneshot::error::TryRecvError::Empty) => {}
|
||||||
|
Err(e) => panic!("error receiving shutdown message: {e}"),
|
||||||
|
}
|
||||||
|
match ui_update_rx.try_recv() {
|
||||||
|
Ok(new_state) => state = new_state,
|
||||||
|
Err(mpsc::error::TryRecvError::Empty) => {}
|
||||||
|
Err(e) => error!("error receiving ui update message: {e}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update LEDs if state changed or if 5 seconds have passed since last update
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
let should_update = !invisible
|
||||||
|
&& (state != last_state
|
||||||
|
|| now.duration_since(last_update) >= Duration::from_secs(5));
|
||||||
|
|
||||||
|
if should_update {
|
||||||
|
match state {
|
||||||
|
DisplayState::Paused => {
|
||||||
|
led_off(led!("red")).await;
|
||||||
|
led_off(led!("green")).await;
|
||||||
|
led_on(led!("wifi")).await;
|
||||||
|
}
|
||||||
|
DisplayState::Recording => {
|
||||||
|
led_off(led!("red")).await;
|
||||||
|
led_off(led!("wifi")).await;
|
||||||
|
led_on(led!("green")).await;
|
||||||
|
}
|
||||||
|
DisplayState::WarningDetected { .. } => {
|
||||||
|
led_off(led!("green")).await;
|
||||||
|
led_off(led!("wifi")).await;
|
||||||
|
led_on(led!("red")).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
last_state = state;
|
||||||
|
last_update = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
56
daemon/src/display/wingtech.rs
Normal file
56
daemon/src/display/wingtech.rs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
use crate::config;
|
||||||
|
use crate::display::DisplayState;
|
||||||
|
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
|
||||||
|
/// Display support for the Wingtech CT2MHS01 hotspot.
|
||||||
|
///
|
||||||
|
/// Tested on (from `/etc/wt_version`):
|
||||||
|
/// WT_INNER_VERSION=SW_Q89323AA1_V057_M10_CRICKET_USR_MP
|
||||||
|
/// WT_PRODUCTION_VERSION=CT2MHS01_0.04.55
|
||||||
|
/// WT_HARDWARE_VERSION=89323_1_20
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use 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;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl GenericFramebuffer for Framebuffer {
|
||||||
|
fn dimensions(&self) -> Dimensions {
|
||||||
|
Dimensions {
|
||||||
|
height: 128,
|
||||||
|
width: 160,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_buffer(&mut self, buffer: Vec<(u8, u8, u8)>) {
|
||||||
|
let mut raw_buffer = Vec::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());
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::fs::write(FB_PATH, &raw_buffer).await.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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -15,4 +15,10 @@ pub enum RayhunterError {
|
|||||||
QmdlStoreError(#[from] RecordingStoreError),
|
QmdlStoreError(#[from] RecordingStoreError),
|
||||||
#[error("No QMDL store found at path {0}, but can't create a new one due to debug mode")]
|
#[error("No QMDL store found at path {0}, but can't create a new one due to debug mode")]
|
||||||
NoStoreDebugMode(String),
|
NoStoreDebugMode(String),
|
||||||
|
#[error("Error parsing file to determine battery level")]
|
||||||
|
BatteryLevelParseError,
|
||||||
|
#[error("Error parsing file to determine whether device is plugged in")]
|
||||||
|
BatteryPluggedInStatusParseError,
|
||||||
|
#[error("The requested functionality is not supported for this device")]
|
||||||
|
FunctionNotSupportedForDeviceError,
|
||||||
}
|
}
|
||||||
131
daemon/src/key_input.rs
Normal file
131
daemon/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
|
||||||
|
&& 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,41 +1,50 @@
|
|||||||
mod analysis;
|
mod analysis;
|
||||||
|
mod battery;
|
||||||
mod config;
|
mod config;
|
||||||
mod diag;
|
mod diag;
|
||||||
mod display;
|
mod display;
|
||||||
mod dummy_analyzer;
|
|
||||||
mod error;
|
mod error;
|
||||||
|
mod key_input;
|
||||||
|
mod notifications;
|
||||||
mod pcap;
|
mod pcap;
|
||||||
mod qmdl_store;
|
mod qmdl_store;
|
||||||
mod server;
|
mod server;
|
||||||
mod stats;
|
mod stats;
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
use crate::config::{parse_args, parse_config};
|
use crate::config::{parse_args, parse_config};
|
||||||
use crate::diag::run_diag_read_thread;
|
use crate::diag::run_diag_read_thread;
|
||||||
use crate::error::RayhunterError;
|
use crate::error::RayhunterError;
|
||||||
|
use crate::notifications::{NotificationService, run_notification_worker};
|
||||||
use crate::pcap::get_pcap;
|
use crate::pcap::get_pcap;
|
||||||
use crate::qmdl_store::RecordingStore;
|
use crate::qmdl_store::RecordingStore;
|
||||||
use crate::server::{get_qmdl, serve_static, ServerState};
|
use crate::server::{
|
||||||
use crate::stats::get_system_stats;
|
ServerState, debug_set_display_state, get_config, get_qmdl, get_zip, serve_static, set_config,
|
||||||
|
};
|
||||||
|
use crate::stats::{get_qmdl_manifest, get_system_stats};
|
||||||
|
|
||||||
use analysis::{
|
use analysis::{
|
||||||
get_analysis_status, run_analysis_thread, start_analysis, AnalysisCtrlMessage, AnalysisStatus,
|
AnalysisCtrlMessage, AnalysisStatus, get_analysis_status, run_analysis_thread, start_analysis,
|
||||||
};
|
};
|
||||||
|
use axum::Router;
|
||||||
use axum::response::Redirect;
|
use axum::response::Redirect;
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use axum::Router;
|
|
||||||
use diag::{
|
use diag::{
|
||||||
delete_all_recordings, delete_recording, get_analysis_report, start_recording, stop_recording,
|
DiagDeviceCtrlMessage, delete_all_recordings, delete_recording, get_analysis_report,
|
||||||
DiagDeviceCtrlMessage,
|
start_recording, stop_recording,
|
||||||
};
|
};
|
||||||
use log::{error, info};
|
use log::{error, info};
|
||||||
use qmdl_store::RecordingStoreError;
|
use qmdl_store::RecordingStoreError;
|
||||||
|
use rayhunter::Device;
|
||||||
use rayhunter::diag_device::DiagDevice;
|
use rayhunter::diag_device::DiagDevice;
|
||||||
use stats::get_qmdl_manifest;
|
use stats::get_log;
|
||||||
use std::net::SocketAddr;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
|
use tokio::select;
|
||||||
use tokio::sync::mpsc::{self, Sender};
|
use tokio::sync::mpsc::{self, Sender};
|
||||||
use tokio::sync::{oneshot, RwLock};
|
use tokio::sync::{RwLock, oneshot};
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
use tokio_util::task::TaskTracker;
|
use tokio_util::task::TaskTracker;
|
||||||
|
|
||||||
@@ -45,8 +54,10 @@ fn get_router() -> AppRouter {
|
|||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/pcap/{name}", get(get_pcap))
|
.route("/api/pcap/{name}", get(get_pcap))
|
||||||
.route("/api/qmdl/{name}", get(get_qmdl))
|
.route("/api/qmdl/{name}", get(get_qmdl))
|
||||||
|
.route("/api/zip/{name}", get(get_zip))
|
||||||
.route("/api/system-stats", get(get_system_stats))
|
.route("/api/system-stats", get(get_system_stats))
|
||||||
.route("/api/qmdl-manifest", get(get_qmdl_manifest))
|
.route("/api/qmdl-manifest", get(get_qmdl_manifest))
|
||||||
|
.route("/api/log", get(get_log))
|
||||||
.route("/api/start-recording", post(start_recording))
|
.route("/api/start-recording", post(start_recording))
|
||||||
.route("/api/stop-recording", post(stop_recording))
|
.route("/api/stop-recording", post(stop_recording))
|
||||||
.route("/api/delete-recording/{name}", post(delete_recording))
|
.route("/api/delete-recording/{name}", post(delete_recording))
|
||||||
@@ -54,6 +65,9 @@ fn get_router() -> AppRouter {
|
|||||||
.route("/api/analysis-report/{name}", get(get_analysis_report))
|
.route("/api/analysis-report/{name}", get(get_analysis_report))
|
||||||
.route("/api/analysis", get(get_analysis_status))
|
.route("/api/analysis", get(get_analysis_status))
|
||||||
.route("/api/analysis/{name}", post(start_analysis))
|
.route("/api/analysis/{name}", post(start_analysis))
|
||||||
|
.route("/api/config", get(get_config))
|
||||||
|
.route("/api/config", post(set_config))
|
||||||
|
.route("/api/debug/display-state", post(debug_set_display_state))
|
||||||
.route("/", get(|| async { Redirect::permanent("/index.html") }))
|
.route("/", get(|| async { Redirect::permanent("/index.html") }))
|
||||||
.route("/{*path}", get(serve_static))
|
.route("/{*path}", get(serve_static))
|
||||||
}
|
}
|
||||||
@@ -63,14 +77,14 @@ fn get_router() -> AppRouter {
|
|||||||
// (i.e. user hit ctrl+c)
|
// (i.e. user hit ctrl+c)
|
||||||
async fn run_server(
|
async fn run_server(
|
||||||
task_tracker: &TaskTracker,
|
task_tracker: &TaskTracker,
|
||||||
config: &config::Config,
|
|
||||||
state: Arc<ServerState>,
|
state: Arc<ServerState>,
|
||||||
server_shutdown_rx: oneshot::Receiver<()>,
|
server_shutdown_rx: oneshot::Receiver<()>,
|
||||||
) -> JoinHandle<()> {
|
) -> JoinHandle<()> {
|
||||||
info!("spinning up server");
|
info!("spinning up server");
|
||||||
let app = get_router().with_state(state);
|
let addr = SocketAddr::from(([0, 0, 0, 0], state.config.port));
|
||||||
let addr = SocketAddr::from(([0, 0, 0, 0], config.port));
|
|
||||||
let listener = TcpListener::bind(&addr).await.unwrap();
|
let listener = TcpListener::bind(&addr).await.unwrap();
|
||||||
|
let app = get_router().with_state(state);
|
||||||
|
|
||||||
task_tracker.spawn(async move {
|
task_tracker.spawn(async move {
|
||||||
info!("The orca is hunting for stingrays...");
|
info!("The orca is hunting for stingrays...");
|
||||||
axum::serve(listener, app)
|
axum::serve(listener, app)
|
||||||
@@ -87,7 +101,7 @@ async fn server_shutdown_signal(server_shutdown_rx: oneshot::Receiver<()>) {
|
|||||||
|
|
||||||
// Loads a RecordingStore if one exists, and if not, only create one if we're
|
// 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
|
// 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.
|
// mode, try to recover the manifest from the existing QMDL files
|
||||||
async fn init_qmdl_store(config: &config::Config) -> Result<RecordingStore, RayhunterError> {
|
async fn init_qmdl_store(config: &config::Config) -> Result<RecordingStore, RayhunterError> {
|
||||||
let store_exists = RecordingStore::exists(&config.qmdl_store_path).await?;
|
let store_exists = RecordingStore::exists(&config.qmdl_store_path).await?;
|
||||||
if config.debug_mode {
|
if config.debug_mode {
|
||||||
@@ -102,9 +116,9 @@ async fn init_qmdl_store(config: &config::Config) -> Result<RecordingStore, Rayh
|
|||||||
match RecordingStore::load(&config.qmdl_store_path).await {
|
match RecordingStore::load(&config.qmdl_store_path).await {
|
||||||
Ok(store) => Ok(store),
|
Ok(store) => Ok(store),
|
||||||
Err(RecordingStoreError::ParseManifestError(err)) => {
|
Err(RecordingStoreError::ParseManifestError(err)) => {
|
||||||
error!("failed to parse QMDL manifest: {}", err);
|
error!("failed to parse QMDL manifest: {err}");
|
||||||
info!("creating new empty manifest...");
|
info!("recovering manifest from existing QMDL files...");
|
||||||
Ok(RecordingStore::create(&config.qmdl_store_path).await?)
|
Ok(RecordingStore::recover(&config.qmdl_store_path).await?)
|
||||||
}
|
}
|
||||||
Err(err) => Err(err.into()),
|
Err(err) => Err(err.into()),
|
||||||
}
|
}
|
||||||
@@ -116,57 +130,88 @@ async fn init_qmdl_store(config: &config::Config) -> Result<RecordingStore, Rayh
|
|||||||
// Start a thread that'll track when user hits ctrl+c. When that happens,
|
// Start a thread that'll track when user hits ctrl+c. When that happens,
|
||||||
// trigger various cleanup tasks, including sending signals to other threads to
|
// trigger various cleanup tasks, including sending signals to other threads to
|
||||||
// shutdown
|
// shutdown
|
||||||
fn run_ctrl_c_thread(
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn run_shutdown_thread(
|
||||||
task_tracker: &TaskTracker,
|
task_tracker: &TaskTracker,
|
||||||
diag_device_sender: Sender<DiagDeviceCtrlMessage>,
|
diag_device_sender: Sender<DiagDeviceCtrlMessage>,
|
||||||
|
daemon_restart_rx: oneshot::Receiver<()>,
|
||||||
|
should_restart_flag: Arc<AtomicBool>,
|
||||||
server_shutdown_tx: oneshot::Sender<()>,
|
server_shutdown_tx: oneshot::Sender<()>,
|
||||||
maybe_ui_shutdown_tx: Option<oneshot::Sender<()>>,
|
maybe_ui_shutdown_tx: Option<oneshot::Sender<()>>,
|
||||||
|
maybe_key_input_shutdown_tx: Option<oneshot::Sender<()>>,
|
||||||
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
|
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
|
||||||
analysis_tx: Sender<AnalysisCtrlMessage>,
|
analysis_tx: Sender<AnalysisCtrlMessage>,
|
||||||
) -> JoinHandle<Result<(), RayhunterError>> {
|
) -> JoinHandle<Result<(), RayhunterError>> {
|
||||||
|
info!("create shutdown thread");
|
||||||
|
|
||||||
task_tracker.spawn(async move {
|
task_tracker.spawn(async move {
|
||||||
match tokio::signal::ctrl_c().await {
|
select! {
|
||||||
Ok(()) => {
|
res = tokio::signal::ctrl_c() => {
|
||||||
let mut qmdl_store = qmdl_store_lock.write().await;
|
if let Err(err) = res {
|
||||||
if qmdl_store.current_entry.is_some() {
|
error!("Unable to listen for shutdown signal: {err}");
|
||||||
info!("Closing current QMDL entry...");
|
|
||||||
qmdl_store.close_current_entry().await?;
|
|
||||||
info!("Done!");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
server_shutdown_tx
|
should_restart_flag.store(false, Ordering::Relaxed);
|
||||||
.send(())
|
}
|
||||||
.expect("couldn't send server shutdown signal");
|
res = daemon_restart_rx => {
|
||||||
info!("sending UI shutdown");
|
if let Err(err) = res {
|
||||||
if let Some(ui_shutdown_tx) = maybe_ui_shutdown_tx {
|
error!("Unable to listen for shutdown signal: {err}");
|
||||||
ui_shutdown_tx
|
|
||||||
.send(())
|
|
||||||
.expect("couldn't send ui shutdown signal");
|
|
||||||
}
|
}
|
||||||
diag_device_sender
|
|
||||||
.send(DiagDeviceCtrlMessage::Exit)
|
should_restart_flag.store(true, Ordering::Relaxed);
|
||||||
.await
|
|
||||||
.expect("couldn't send Exit message to diag thread");
|
|
||||||
analysis_tx
|
|
||||||
.send(AnalysisCtrlMessage::Exit)
|
|
||||||
.await
|
|
||||||
.expect("couldn't send Exit message to analysis thread");
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
error!("Unable to listen for shutdown signal: {}", err);
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||||
|
if qmdl_store.current_entry.is_some() {
|
||||||
|
info!("Closing current QMDL entry...");
|
||||||
|
qmdl_store.close_current_entry().await?;
|
||||||
|
info!("Done!");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
server_shutdown_tx
|
||||||
|
.send(())
|
||||||
|
.expect("couldn't send server shutdown signal");
|
||||||
|
if let Some(ui_shutdown_tx) = maybe_ui_shutdown_tx {
|
||||||
|
let _ = ui_shutdown_tx.send(());
|
||||||
|
}
|
||||||
|
if let Some(key_input_shutdown_tx) = maybe_key_input_shutdown_tx {
|
||||||
|
let _ = key_input_shutdown_tx.send(());
|
||||||
|
}
|
||||||
|
diag_device_sender
|
||||||
|
.send(DiagDeviceCtrlMessage::Exit)
|
||||||
|
.await
|
||||||
|
.expect("couldn't send Exit message to diag thread");
|
||||||
|
analysis_tx
|
||||||
|
.send(AnalysisCtrlMessage::Exit)
|
||||||
|
.await
|
||||||
|
.expect("couldn't send Exit message to analysis thread");
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main(flavor = "current_thread")]
|
||||||
async fn main() -> Result<(), RayhunterError> {
|
async fn main() -> Result<(), RayhunterError> {
|
||||||
env_logger::init();
|
env_logger::init();
|
||||||
|
|
||||||
let args = parse_args();
|
rustls_rustcrypto::provider()
|
||||||
let config = parse_config(&args.config_path)?;
|
.install_default()
|
||||||
|
.expect("Couldn't install rustcrypto provider");
|
||||||
|
|
||||||
|
let args = parse_args();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let config = parse_config(&args.config_path).await?;
|
||||||
|
if !run_with_config(&args, config).await? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_with_config(
|
||||||
|
args: &config::Args,
|
||||||
|
config: config::Config,
|
||||||
|
) -> Result<bool, RayhunterError> {
|
||||||
// TaskTrackers give us an interface to spawn tokio threads, and then
|
// TaskTrackers give us an interface to spawn tokio threads, and then
|
||||||
// eventually await all of them ending
|
// eventually await all of them ending
|
||||||
let task_tracker = TaskTracker::new();
|
let task_tracker = TaskTracker::new();
|
||||||
@@ -175,14 +220,19 @@ async fn main() -> Result<(), RayhunterError> {
|
|||||||
let store = init_qmdl_store(&config).await?;
|
let store = init_qmdl_store(&config).await?;
|
||||||
let analysis_status = AnalysisStatus::new(&store);
|
let analysis_status = AnalysisStatus::new(&store);
|
||||||
let qmdl_store_lock = Arc::new(RwLock::new(store));
|
let qmdl_store_lock = Arc::new(RwLock::new(store));
|
||||||
let (tx, rx) = mpsc::channel::<DiagDeviceCtrlMessage>(1);
|
let (diag_tx, diag_rx) = mpsc::channel::<DiagDeviceCtrlMessage>(1);
|
||||||
let (ui_update_tx, ui_update_rx) = mpsc::channel::<display::DisplayState>(1);
|
let (ui_update_tx, ui_update_rx) = mpsc::channel::<display::DisplayState>(1);
|
||||||
let (analysis_tx, analysis_rx) = mpsc::channel::<AnalysisCtrlMessage>(5);
|
let (analysis_tx, analysis_rx) = mpsc::channel::<AnalysisCtrlMessage>(5);
|
||||||
let mut maybe_ui_shutdown_tx = None;
|
let mut maybe_ui_shutdown_tx = None;
|
||||||
|
let mut maybe_key_input_shutdown_tx = None;
|
||||||
|
|
||||||
|
let notification_service = NotificationService::new(config.ntfy_url.clone());
|
||||||
|
|
||||||
if !config.debug_mode {
|
if !config.debug_mode {
|
||||||
let (ui_shutdown_tx, ui_shutdown_rx) = oneshot::channel();
|
let (ui_shutdown_tx, ui_shutdown_rx) = oneshot::channel();
|
||||||
maybe_ui_shutdown_tx = Some(ui_shutdown_tx);
|
maybe_ui_shutdown_tx = Some(ui_shutdown_tx);
|
||||||
let mut dev = DiagDevice::new()
|
info!("Using configuration for device: {0:?}", config.device);
|
||||||
|
let mut dev = DiagDevice::new(&config.device)
|
||||||
.await
|
.await
|
||||||
.map_err(RayhunterError::DiagInitError)?;
|
.map_err(RayhunterError::DiagInitError)?;
|
||||||
dev.config_logs()
|
dev.config_logs()
|
||||||
@@ -193,47 +243,78 @@ async fn main() -> Result<(), RayhunterError> {
|
|||||||
run_diag_read_thread(
|
run_diag_read_thread(
|
||||||
&task_tracker,
|
&task_tracker,
|
||||||
dev,
|
dev,
|
||||||
rx,
|
diag_rx,
|
||||||
|
diag_tx.clone(),
|
||||||
ui_update_tx.clone(),
|
ui_update_tx.clone(),
|
||||||
qmdl_store_lock.clone(),
|
qmdl_store_lock.clone(),
|
||||||
config.enable_dummy_analyzer,
|
analysis_tx.clone(),
|
||||||
|
config.analyzers.clone(),
|
||||||
|
notification_service.new_handler(),
|
||||||
);
|
);
|
||||||
info!("Starting UI");
|
info!("Starting UI");
|
||||||
display::update_ui(&task_tracker, &config, ui_shutdown_rx, ui_update_rx);
|
|
||||||
|
let update_ui = match &config.device {
|
||||||
|
Device::Orbic => display::orbic::update_ui,
|
||||||
|
Device::Tplink => display::tplink::update_ui,
|
||||||
|
Device::Tmobile => display::tmobile::update_ui,
|
||||||
|
Device::Wingtech => display::wingtech::update_ui,
|
||||||
|
Device::Pinephone => display::headless::update_ui,
|
||||||
|
Device::Uz801 => display::uz801::update_ui,
|
||||||
|
};
|
||||||
|
update_ui(&task_tracker, &config, 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 (daemon_restart_tx, daemon_restart_rx) = oneshot::channel::<()>();
|
||||||
let (server_shutdown_tx, server_shutdown_rx) = oneshot::channel::<()>();
|
let (server_shutdown_tx, server_shutdown_rx) = oneshot::channel::<()>();
|
||||||
info!("create shutdown thread");
|
|
||||||
let analysis_status_lock = Arc::new(RwLock::new(analysis_status));
|
let analysis_status_lock = Arc::new(RwLock::new(analysis_status));
|
||||||
run_analysis_thread(
|
run_analysis_thread(
|
||||||
&task_tracker,
|
&task_tracker,
|
||||||
analysis_rx,
|
analysis_rx,
|
||||||
qmdl_store_lock.clone(),
|
qmdl_store_lock.clone(),
|
||||||
analysis_status_lock.clone(),
|
analysis_status_lock.clone(),
|
||||||
config.enable_dummy_analyzer,
|
config.analyzers.clone(),
|
||||||
);
|
);
|
||||||
run_ctrl_c_thread(
|
let should_restart_flag = Arc::new(AtomicBool::new(false));
|
||||||
|
|
||||||
|
run_shutdown_thread(
|
||||||
&task_tracker,
|
&task_tracker,
|
||||||
tx.clone(),
|
diag_tx.clone(),
|
||||||
|
daemon_restart_rx,
|
||||||
|
should_restart_flag.clone(),
|
||||||
server_shutdown_tx,
|
server_shutdown_tx,
|
||||||
maybe_ui_shutdown_tx,
|
maybe_ui_shutdown_tx,
|
||||||
|
maybe_key_input_shutdown_tx,
|
||||||
qmdl_store_lock.clone(),
|
qmdl_store_lock.clone(),
|
||||||
analysis_tx.clone(),
|
analysis_tx.clone(),
|
||||||
);
|
);
|
||||||
|
run_notification_worker(&task_tracker, notification_service);
|
||||||
let state = Arc::new(ServerState {
|
let state = Arc::new(ServerState {
|
||||||
|
config_path: args.config_path.clone(),
|
||||||
|
config,
|
||||||
qmdl_store_lock: qmdl_store_lock.clone(),
|
qmdl_store_lock: qmdl_store_lock.clone(),
|
||||||
diag_device_ctrl_sender: tx,
|
diag_device_ctrl_sender: diag_tx,
|
||||||
ui_update_sender: ui_update_tx,
|
|
||||||
debug_mode: config.debug_mode,
|
|
||||||
analysis_status_lock,
|
analysis_status_lock,
|
||||||
analysis_sender: analysis_tx,
|
analysis_sender: analysis_tx,
|
||||||
|
daemon_restart_tx: Arc::new(RwLock::new(Some(daemon_restart_tx))),
|
||||||
|
ui_update_sender: Some(ui_update_tx),
|
||||||
});
|
});
|
||||||
run_server(&task_tracker, &config, state, server_shutdown_rx).await;
|
run_server(&task_tracker, state, server_shutdown_rx).await;
|
||||||
|
|
||||||
task_tracker.close();
|
task_tracker.close();
|
||||||
task_tracker.wait().await;
|
task_tracker.wait().await;
|
||||||
|
|
||||||
info!("see you space cowboy...");
|
info!("see you space cowboy...");
|
||||||
Ok(())
|
Ok(should_restart_flag.load(Ordering::Relaxed))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
148
daemon/src/notifications.rs
Normal file
148
daemon/src/notifications.rs
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
use std::{
|
||||||
|
cmp::min,
|
||||||
|
collections::HashMap,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
use log::error;
|
||||||
|
use tokio::sync::mpsc::{self, error::TryRecvError};
|
||||||
|
use tokio_util::task::TaskTracker;
|
||||||
|
|
||||||
|
pub struct Notification {
|
||||||
|
message_type: String,
|
||||||
|
message: String,
|
||||||
|
debounce: Option<Duration>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Notification {
|
||||||
|
pub fn new(message_type: String, message: String, debounce: Option<Duration>) -> Self {
|
||||||
|
Notification {
|
||||||
|
message_type,
|
||||||
|
message,
|
||||||
|
debounce,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NotificationStatus {
|
||||||
|
message: String,
|
||||||
|
needs_sending: bool,
|
||||||
|
last_sent: Option<Instant>,
|
||||||
|
last_attempt: Option<Instant>,
|
||||||
|
failed_since_last_success: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct NotificationService {
|
||||||
|
url: Option<String>,
|
||||||
|
tx: mpsc::Sender<Notification>,
|
||||||
|
rx: mpsc::Receiver<Notification>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NotificationService {
|
||||||
|
pub fn new(url: Option<String>) -> Self {
|
||||||
|
let (tx, rx) = mpsc::channel(10);
|
||||||
|
Self { url, tx, rx }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_handler(&self) -> mpsc::Sender<Notification> {
|
||||||
|
self.tx.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_notification_worker(
|
||||||
|
task_tracker: &TaskTracker,
|
||||||
|
mut notification_service: NotificationService,
|
||||||
|
) {
|
||||||
|
task_tracker.spawn(async move {
|
||||||
|
if let Some(url) = notification_service.url
|
||||||
|
&& !url.is_empty()
|
||||||
|
{
|
||||||
|
let mut notification_statuses = HashMap::new();
|
||||||
|
let http_client = reqwest::Client::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Get any notifications since the last time we checked
|
||||||
|
loop {
|
||||||
|
match notification_service.rx.try_recv() {
|
||||||
|
Ok(notification) => {
|
||||||
|
let status = notification_statuses
|
||||||
|
.entry(notification.message_type)
|
||||||
|
.or_insert_with(|| NotificationStatus {
|
||||||
|
message: "".to_string(),
|
||||||
|
needs_sending: true,
|
||||||
|
last_sent: None,
|
||||||
|
last_attempt: None,
|
||||||
|
failed_since_last_success: 0,
|
||||||
|
});
|
||||||
|
// Ignore if we're in the debounce period
|
||||||
|
if let Some(debounce) = notification.debounce
|
||||||
|
&& let Some(last_sent) = status.last_sent
|
||||||
|
&& last_sent.elapsed() < debounce
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
status.message = notification.message;
|
||||||
|
status.needs_sending = true;
|
||||||
|
}
|
||||||
|
Err(TryRecvError::Empty) => {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(TryRecvError::Disconnected) => {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to send pending notifications
|
||||||
|
for notification in notification_statuses.values_mut() {
|
||||||
|
if !notification.needs_sending {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backoff retries, up to a maximum of 256 seconds.
|
||||||
|
if let Some(last_attempt) = notification.last_attempt {
|
||||||
|
let min_wait_time = Duration::from_secs(
|
||||||
|
2u64.pow(min(notification.failed_since_last_success, 8)),
|
||||||
|
);
|
||||||
|
if last_attempt.elapsed() < min_wait_time {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match http_client
|
||||||
|
.post(&url)
|
||||||
|
.body(notification.message.clone())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(response) => {
|
||||||
|
if response.status().is_success() {
|
||||||
|
notification.last_sent = Some(Instant::now());
|
||||||
|
notification.failed_since_last_success = 0;
|
||||||
|
notification.needs_sending = false;
|
||||||
|
} else {
|
||||||
|
notification.failed_since_last_success += 1;
|
||||||
|
notification.last_attempt = Some(Instant::now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to send notification to ntfy: {e}");
|
||||||
|
notification.failed_since_last_success += 1;
|
||||||
|
notification.last_attempt = Some(Instant::now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If there's no url to send to we'll just discard the notifications
|
||||||
|
else {
|
||||||
|
loop {
|
||||||
|
if notification_service.rx.recv().await.is_none() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,19 +1,18 @@
|
|||||||
use crate::ServerState;
|
use crate::ServerState;
|
||||||
|
|
||||||
|
use anyhow::Error;
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, State};
|
||||||
use axum::http::header::CONTENT_TYPE;
|
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
|
use axum::http::header::CONTENT_TYPE;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use futures::TryStreamExt;
|
|
||||||
use log::error;
|
use log::error;
|
||||||
use rayhunter::diag::DataType;
|
use rayhunter::diag::DataType;
|
||||||
use rayhunter::gsmtap_parser;
|
use rayhunter::gsmtap_parser;
|
||||||
use rayhunter::pcap::GsmtapPcapWriter;
|
use rayhunter::pcap::GsmtapPcapWriter;
|
||||||
use rayhunter::qmdl::QmdlReader;
|
use rayhunter::qmdl::QmdlReader;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::{future, pin::pin};
|
use tokio::io::{AsyncRead, AsyncWrite, duplex};
|
||||||
use tokio::io::duplex;
|
|
||||||
use tokio_util::io::ReaderStream;
|
use tokio_util::io::ReaderStream;
|
||||||
|
|
||||||
// Streams a pcap file chunk-by-chunk to the client by reading the QMDL data
|
// Streams a pcap file chunk-by-chunk to the client by reading the QMDL data
|
||||||
@@ -21,12 +20,15 @@ use tokio_util::io::ReaderStream;
|
|||||||
// pcap data to a channel that's piped to the client.
|
// pcap data to a channel that's piped to the client.
|
||||||
pub async fn get_pcap(
|
pub async fn get_pcap(
|
||||||
State(state): State<Arc<ServerState>>,
|
State(state): State<Arc<ServerState>>,
|
||||||
Path(qmdl_name): Path<String>,
|
Path(mut qmdl_name): Path<String>,
|
||||||
) -> Result<Response, (StatusCode, String)> {
|
) -> Result<Response, (StatusCode, String)> {
|
||||||
let qmdl_store = state.qmdl_store_lock.read().await;
|
let qmdl_store = state.qmdl_store_lock.read().await;
|
||||||
|
if qmdl_name.ends_with("pcapng") {
|
||||||
|
qmdl_name = qmdl_name.trim_end_matches(".pcapng").to_string();
|
||||||
|
}
|
||||||
let (entry_index, entry) = qmdl_store.entry_for_name(&qmdl_name).ok_or((
|
let (entry_index, entry) = qmdl_store.entry_for_name(&qmdl_name).ok_or((
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
format!("couldn't find qmdl file with name {}", qmdl_name),
|
format!("couldn't find manifest entry with name {qmdl_name}"),
|
||||||
))?;
|
))?;
|
||||||
if entry.qmdl_size_bytes == 0 {
|
if entry.qmdl_size_bytes == 0 {
|
||||||
return Err((
|
return Err((
|
||||||
@@ -38,39 +40,14 @@ pub async fn get_pcap(
|
|||||||
let qmdl_file = qmdl_store
|
let qmdl_file = qmdl_store
|
||||||
.open_entry_qmdl(entry_index)
|
.open_entry_qmdl(entry_index)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?;
|
||||||
// the QMDL reader should stop at the last successfully written data chunk
|
// the QMDL reader should stop at the last successfully written data chunk
|
||||||
// (entry.size_bytes)
|
// (entry.size_bytes)
|
||||||
let (reader, writer) = duplex(1024);
|
let (reader, writer) = duplex(1024);
|
||||||
let mut pcap_writer = GsmtapPcapWriter::new(writer).await.unwrap();
|
|
||||||
pcap_writer.write_iface_header().await.unwrap();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut reader = QmdlReader::new(qmdl_file, Some(qmdl_size_bytes));
|
if let Err(e) = generate_pcap_data(writer, qmdl_file, qmdl_size_bytes).await {
|
||||||
let mut messages_stream = pin!(reader
|
error!("failed to generate PCAP: {e:?}");
|
||||||
.as_stream()
|
|
||||||
.try_filter(|container| future::ready(container.data_type == DataType::UserSpace)));
|
|
||||||
|
|
||||||
while let Some(container) = messages_stream
|
|
||||||
.try_next()
|
|
||||||
.await
|
|
||||||
.expect("failed getting QMDL container")
|
|
||||||
{
|
|
||||||
for maybe_msg in container.into_messages() {
|
|
||||||
match maybe_msg {
|
|
||||||
Ok(msg) => {
|
|
||||||
let maybe_gsmtap_msg =
|
|
||||||
gsmtap_parser::parse(msg).expect("error parsing gsmtap message");
|
|
||||||
if let Some((timestamp, gsmtap_msg)) = maybe_gsmtap_msg {
|
|
||||||
pcap_writer
|
|
||||||
.write_gsmtap_message(gsmtap_msg, timestamp)
|
|
||||||
.await
|
|
||||||
.expect("error writing pcap packet");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => error!("error parsing message: {:?}", e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -78,3 +55,39 @@ pub async fn get_pcap(
|
|||||||
let body = Body::from_stream(ReaderStream::new(reader));
|
let body = Body::from_stream(ReaderStream::new(reader));
|
||||||
Ok((headers, body).into_response())
|
Ok((headers, body).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn generate_pcap_data<R, W>(
|
||||||
|
writer: W,
|
||||||
|
qmdl_file: R,
|
||||||
|
qmdl_size_bytes: usize,
|
||||||
|
) -> Result<(), Error>
|
||||||
|
where
|
||||||
|
W: AsyncWrite + Unpin + Send,
|
||||||
|
R: AsyncRead + Unpin,
|
||||||
|
{
|
||||||
|
let mut pcap_writer = GsmtapPcapWriter::new(writer).await?;
|
||||||
|
pcap_writer.write_iface_header().await?;
|
||||||
|
|
||||||
|
let mut reader = QmdlReader::new(qmdl_file, Some(qmdl_size_bytes));
|
||||||
|
while let Some(container) = reader.get_next_messages_container().await? {
|
||||||
|
if container.data_type != DataType::UserSpace {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for maybe_msg in container.into_messages() {
|
||||||
|
match maybe_msg {
|
||||||
|
Ok(msg) => {
|
||||||
|
let maybe_gsmtap_msg = gsmtap_parser::parse(msg)?;
|
||||||
|
if let Some((timestamp, gsmtap_msg)) = maybe_gsmtap_msg {
|
||||||
|
pcap_writer
|
||||||
|
.write_gsmtap_message(gsmtap_msg, timestamp)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => error!("error parsing message: {e:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
use std::io::{self, ErrorKind};
|
use std::io::{self, ErrorKind};
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use chrono::{DateTime, Local};
|
use chrono::{DateTime, Local};
|
||||||
|
use log::{info, warn};
|
||||||
use rayhunter::util::RuntimeMetadata;
|
use rayhunter::util::RuntimeMetadata;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tokio::{
|
use tokio::{
|
||||||
fs::{self, try_exists, File, OpenOptions},
|
fs::{self, File, OpenOptions, try_exists},
|
||||||
io::AsyncWriteExt,
|
io::AsyncWriteExt,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,7 +51,6 @@ pub struct ManifestEntry {
|
|||||||
pub start_time: DateTime<Local>,
|
pub start_time: DateTime<Local>,
|
||||||
pub last_message_time: Option<DateTime<Local>>,
|
pub last_message_time: Option<DateTime<Local>>,
|
||||||
pub qmdl_size_bytes: usize,
|
pub qmdl_size_bytes: usize,
|
||||||
pub analysis_size_bytes: usize,
|
|
||||||
pub rayhunter_version: Option<String>,
|
pub rayhunter_version: Option<String>,
|
||||||
pub system_os: Option<String>,
|
pub system_os: Option<String>,
|
||||||
pub arch: Option<String>,
|
pub arch: Option<String>,
|
||||||
@@ -64,7 +65,6 @@ impl ManifestEntry {
|
|||||||
start_time: now,
|
start_time: now,
|
||||||
last_message_time: None,
|
last_message_time: None,
|
||||||
qmdl_size_bytes: 0,
|
qmdl_size_bytes: 0,
|
||||||
analysis_size_bytes: 0,
|
|
||||||
rayhunter_version: Some(metadata.rayhunter_version),
|
rayhunter_version: Some(metadata.rayhunter_version),
|
||||||
system_os: Some(metadata.system_os),
|
system_os: Some(metadata.system_os),
|
||||||
arch: Some(metadata.arch),
|
arch: Some(metadata.arch),
|
||||||
@@ -138,6 +138,83 @@ impl RecordingStore {
|
|||||||
Ok(store)
|
Ok(store)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Does a best-effort attempt to recover the manifest from a directory of
|
||||||
|
// QMDL files. We expect these files to be named like "<timestamp>.qmdl",
|
||||||
|
// and skip any files which don't match that pattern.
|
||||||
|
pub async fn recover<P>(path: P) -> Result<Self, RecordingStoreError>
|
||||||
|
where
|
||||||
|
P: AsRef<Path>,
|
||||||
|
{
|
||||||
|
let mut dir_entries = fs::read_dir(path.as_ref())
|
||||||
|
.await
|
||||||
|
.map_err(RecordingStoreError::OpenDirError)?;
|
||||||
|
let mut manifest_entries = Vec::new();
|
||||||
|
|
||||||
|
while let Some(entry) = dir_entries
|
||||||
|
.next_entry()
|
||||||
|
.await
|
||||||
|
.map_err(RecordingStoreError::OpenDirError)?
|
||||||
|
{
|
||||||
|
let os_filename = entry.file_name();
|
||||||
|
let Some(filename) = os_filename.to_str() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !filename.ends_with(".qmdl") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let stem = filename.trim_end_matches(".qmdl");
|
||||||
|
let Ok(start_timestamp) = stem.parse::<i64>() else {
|
||||||
|
warn!("QMDL file has invalid name {os_filename:?}, skipping");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let metadata = match entry.metadata().await {
|
||||||
|
Ok(metadata) => metadata,
|
||||||
|
Err(err) => {
|
||||||
|
warn!("failed to read QMDL file metadata: {err:?}, skipping");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(start_time) = DateTime::from_timestamp(start_timestamp, 0) else {
|
||||||
|
warn!("QMDL filename {os_filename:?} gave an invalid timestamp, skipping");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(last_message_time) = metadata.modified() else {
|
||||||
|
warn!("failed to get modified time for QMDL file {os_filename:?}, skipping");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
info!("successfully recovered QMDL entry {os_filename:?}!");
|
||||||
|
manifest_entries.push(ManifestEntry {
|
||||||
|
name: stem.to_string(),
|
||||||
|
start_time: start_time.into(),
|
||||||
|
last_message_time: Some(last_message_time.into()),
|
||||||
|
qmdl_size_bytes: metadata.size() as usize,
|
||||||
|
rayhunter_version: None,
|
||||||
|
system_os: None,
|
||||||
|
arch: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// sort chronologically
|
||||||
|
manifest_entries.sort_by(|a, b| a.start_time.cmp(&b.start_time));
|
||||||
|
|
||||||
|
let mut store = RecordingStore {
|
||||||
|
path: path.as_ref().to_path_buf(),
|
||||||
|
manifest: Manifest {
|
||||||
|
entries: manifest_entries,
|
||||||
|
},
|
||||||
|
current_entry: None,
|
||||||
|
};
|
||||||
|
store.write_manifest().await?;
|
||||||
|
|
||||||
|
Ok(store)
|
||||||
|
}
|
||||||
|
|
||||||
async fn read_manifest<P>(path: P) -> Result<Manifest, RecordingStoreError>
|
async fn read_manifest<P>(path: P) -> Result<Manifest, RecordingStoreError>
|
||||||
where
|
where
|
||||||
P: AsRef<Path>,
|
P: AsRef<Path>,
|
||||||
@@ -202,7 +279,6 @@ impl RecordingStore {
|
|||||||
.open(entry.get_analysis_filepath(&self.path))
|
.open(entry.get_analysis_filepath(&self.path))
|
||||||
.await
|
.await
|
||||||
.map_err(RecordingStoreError::ReadFileError)?;
|
.map_err(RecordingStoreError::ReadFileError)?;
|
||||||
self.update_entry_analysis_size(entry_index, 0).await?;
|
|
||||||
Ok(file)
|
Ok(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,17 +304,9 @@ impl RecordingStore {
|
|||||||
self.write_manifest().await
|
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> {
|
|
||||||
self.manifest.entries[entry_index].analysis_size_bytes = size_bytes;
|
|
||||||
self.write_manifest().await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn write_manifest(&mut self) -> Result<(), RecordingStoreError> {
|
async fn write_manifest(&mut self) -> Result<(), RecordingStoreError> {
|
||||||
|
// we don't technically need a mutable reference to `self` here, but it
|
||||||
|
// does prevent multiple concurrent writes across different threads
|
||||||
let tmp_path = self.path.join("manifest.toml.new");
|
let tmp_path = self.path.join("manifest.toml.new");
|
||||||
let mut manifest_tmp_file = File::create(&tmp_path)
|
let mut manifest_tmp_file = File::create(&tmp_path)
|
||||||
.await
|
.await
|
||||||
@@ -273,20 +341,32 @@ impl RecordingStore {
|
|||||||
Some((entry_index, &self.manifest.entries[entry_index]))
|
Some((entry_index, &self.manifest.entries[entry_index]))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_entry(&mut self, name: &str) -> Result<ManifestEntry, RecordingStoreError> {
|
pub fn is_current_entry(&self, name: &str) -> bool {
|
||||||
|
match self.current_entry {
|
||||||
|
Some(idx) => match self.manifest.entries.get(idx) {
|
||||||
|
Some(entry) => entry.name == name,
|
||||||
|
None => false,
|
||||||
|
},
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_entry(&mut self, name: &str) -> Result<(), RecordingStoreError> {
|
||||||
let entry_to_delete_idx = self
|
let entry_to_delete_idx = self
|
||||||
.manifest
|
.manifest
|
||||||
.entries
|
.entries
|
||||||
.iter()
|
.iter()
|
||||||
.position(|entry| entry.name == name)
|
.position(|entry| entry.name == name)
|
||||||
.ok_or(RecordingStoreError::NoSuchEntryError)?;
|
.ok_or(RecordingStoreError::NoSuchEntryError)?;
|
||||||
if let Some(current_entry) = self.current_entry {
|
match self.current_entry {
|
||||||
if current_entry == entry_to_delete_idx {
|
Some(current_entry) if current_entry == entry_to_delete_idx => {
|
||||||
self.close_current_entry().await?;
|
self.close_current_entry().await?;
|
||||||
} else {
|
}
|
||||||
|
Some(current_entry) => {
|
||||||
self.current_entry = Some(current_entry - 1);
|
self.current_entry = Some(current_entry - 1);
|
||||||
}
|
}
|
||||||
}
|
None => {}
|
||||||
|
};
|
||||||
let entry_to_delete = self.manifest.entries.remove(entry_to_delete_idx);
|
let entry_to_delete = self.manifest.entries.remove(entry_to_delete_idx);
|
||||||
self.write_manifest().await?;
|
self.write_manifest().await?;
|
||||||
let qmdl_filepath = entry_to_delete.get_qmdl_filepath(&self.path);
|
let qmdl_filepath = entry_to_delete.get_qmdl_filepath(&self.path);
|
||||||
@@ -297,7 +377,7 @@ impl RecordingStore {
|
|||||||
remove_file_if_exists(&analysis_filepath)
|
remove_file_if_exists(&analysis_filepath)
|
||||||
.await
|
.await
|
||||||
.map_err(RecordingStoreError::DeleteFileError)?;
|
.map_err(RecordingStoreError::DeleteFileError)?;
|
||||||
Ok(entry_to_delete)
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_all_entries(&mut self) -> Result<(), RecordingStoreError> {
|
pub async fn delete_all_entries(&mut self) -> Result<(), RecordingStoreError> {
|
||||||
@@ -369,9 +449,11 @@ mod tests {
|
|||||||
RecordingStore::read_manifest(dir.path()).await.unwrap(),
|
RecordingStore::read_manifest(dir.path()).await.unwrap(),
|
||||||
store.manifest
|
store.manifest
|
||||||
);
|
);
|
||||||
assert!(store.manifest.entries[entry_index]
|
assert!(
|
||||||
.last_message_time
|
store.manifest.entries[entry_index]
|
||||||
.is_none());
|
.last_message_time
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
|
||||||
store
|
store
|
||||||
.update_entry_qmdl_size(entry_index, 1000)
|
.update_entry_qmdl_size(entry_index, 1000)
|
||||||
371
daemon/src/server.rs
Normal file
371
daemon/src/server.rs
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
use anyhow::Error;
|
||||||
|
use async_zip::Compression;
|
||||||
|
use async_zip::ZipEntryBuilder;
|
||||||
|
use async_zip::tokio::write::ZipFileWriter;
|
||||||
|
use axum::Json;
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::extract::Path;
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::header::{self, CONTENT_LENGTH, CONTENT_TYPE};
|
||||||
|
use axum::http::{HeaderValue, StatusCode};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use log::{error, warn};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::fs::write;
|
||||||
|
use tokio::io::{AsyncReadExt, copy, duplex};
|
||||||
|
use tokio::sync::mpsc::Sender;
|
||||||
|
use tokio::sync::{RwLock, oneshot};
|
||||||
|
use tokio_util::compat::FuturesAsyncWriteCompatExt;
|
||||||
|
use tokio_util::io::ReaderStream;
|
||||||
|
|
||||||
|
use crate::DiagDeviceCtrlMessage;
|
||||||
|
use crate::analysis::{AnalysisCtrlMessage, AnalysisStatus};
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::display::DisplayState;
|
||||||
|
use crate::pcap::generate_pcap_data;
|
||||||
|
use crate::qmdl_store::RecordingStore;
|
||||||
|
|
||||||
|
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 analysis_status_lock: Arc<RwLock<AnalysisStatus>>,
|
||||||
|
pub analysis_sender: Sender<AnalysisCtrlMessage>,
|
||||||
|
pub daemon_restart_tx: Arc<RwLock<Option<oneshot::Sender<()>>>>,
|
||||||
|
pub ui_update_sender: Option<Sender<DisplayState>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_qmdl(
|
||||||
|
State(state): State<Arc<ServerState>>,
|
||||||
|
Path(qmdl_name): Path<String>,
|
||||||
|
) -> Result<Response, (StatusCode, String)> {
|
||||||
|
let qmdl_idx = qmdl_name.trim_end_matches(".qmdl");
|
||||||
|
let qmdl_store = state.qmdl_store_lock.read().await;
|
||||||
|
let (entry_index, entry) = qmdl_store.entry_for_name(qmdl_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"),
|
||||||
|
(CONTENT_LENGTH, &entry.qmdl_size_bytes.to_string()),
|
||||||
|
];
|
||||||
|
let body = Body::from_stream(qmdl_stream);
|
||||||
|
Ok((headers, body).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn serve_static(
|
||||||
|
State(_): State<Arc<ServerState>>,
|
||||||
|
Path(path): Path<String>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let path = path.trim_start_matches('/');
|
||||||
|
|
||||||
|
match path {
|
||||||
|
"rayhunter_icon.png" => (
|
||||||
|
[(header::CONTENT_TYPE, HeaderValue::from_static("image/png"))],
|
||||||
|
include_bytes!("../web/build/rayhunter_icon.png"),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
"rayhunter_orca_only.png" => (
|
||||||
|
[(header::CONTENT_TYPE, HeaderValue::from_static("image/png"))],
|
||||||
|
include_bytes!("../web/build/rayhunter_orca_only.png"),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
"rayhunter_text.png" => (
|
||||||
|
[(header::CONTENT_TYPE, HeaderValue::from_static("image/png"))],
|
||||||
|
include_bytes!("../web/build/rayhunter_text.png"),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
"favicon.png" => (
|
||||||
|
[(header::CONTENT_TYPE, HeaderValue::from_static("image/png"))],
|
||||||
|
include_bytes!("../web/build/favicon.png"),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
"index.html" => (
|
||||||
|
[
|
||||||
|
(header::CONTENT_TYPE, HeaderValue::from_static("text/html")),
|
||||||
|
(header::CONTENT_ENCODING, HeaderValue::from_static("gzip")),
|
||||||
|
],
|
||||||
|
include_bytes!("../web/build/index.html.gz"),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
path => {
|
||||||
|
warn!("404 on path: {path}");
|
||||||
|
StatusCode::NOT_FOUND.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn debug_set_display_state(
|
||||||
|
State(state): State<Arc<ServerState>>,
|
||||||
|
Json(display_state): Json<DisplayState>,
|
||||||
|
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||||
|
if let Some(ui_sender) = &state.ui_update_sender {
|
||||||
|
ui_sender.send(display_state).await.map_err(|_| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"failed to send display state update".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
"display state updated successfully".to_string(),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Err((
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"display system not available".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
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 (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,
|
||||||
|
analysis_status_lock: Arc::new(RwLock::new(analysis_status)),
|
||||||
|
analysis_sender: analysis_tx,
|
||||||
|
daemon_restart_tx: Arc::new(RwLock::new(None)),
|
||||||
|
ui_update_sender: 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"),]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::qmdl_store::ManifestEntry;
|
use crate::battery::get_battery_status;
|
||||||
|
use crate::error::RayhunterError;
|
||||||
use crate::server::ServerState;
|
use crate::server::ServerState;
|
||||||
|
use crate::{battery::BatteryState, qmdl_store::ManifestEntry};
|
||||||
|
|
||||||
|
use axum::Json;
|
||||||
use axum::extract::State;
|
use axum::extract::State;
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::Json;
|
|
||||||
use log::error;
|
use log::error;
|
||||||
use rayhunter::util::RuntimeMetadata;
|
use rayhunter::{Device, util::RuntimeMetadata};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
|
|
||||||
@@ -16,14 +18,24 @@ pub struct SystemStats {
|
|||||||
pub disk_stats: DiskStats,
|
pub disk_stats: DiskStats,
|
||||||
pub memory_stats: MemoryStats,
|
pub memory_stats: MemoryStats,
|
||||||
pub runtime_metadata: RuntimeMetadata,
|
pub runtime_metadata: RuntimeMetadata,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub battery_status: Option<BatteryState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SystemStats {
|
impl SystemStats {
|
||||||
pub async fn new(qmdl_path: &str) -> Result<Self, String> {
|
pub async fn new(qmdl_path: &str, device: &Device) -> Result<Self, String> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
disk_stats: DiskStats::new(qmdl_path).await?,
|
disk_stats: DiskStats::new(qmdl_path, device).await?,
|
||||||
memory_stats: MemoryStats::new().await?,
|
memory_stats: MemoryStats::new(device).await?,
|
||||||
runtime_metadata: RuntimeMetadata::new(),
|
runtime_metadata: RuntimeMetadata::new(),
|
||||||
|
battery_status: match get_battery_status(device).await {
|
||||||
|
Ok(status) => Some(status),
|
||||||
|
Err(RayhunterError::FunctionNotSupportedForDeviceError) => None,
|
||||||
|
Err(err) => {
|
||||||
|
log::error!("Failed to get battery status: {err}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,13 +52,22 @@ pub struct DiskStats {
|
|||||||
|
|
||||||
impl DiskStats {
|
impl DiskStats {
|
||||||
// runs "df -h <qmdl_path>" to get storage statistics for the partition containing
|
// runs "df -h <qmdl_path>" to get storage statistics for the partition containing
|
||||||
// the QMDL file
|
// the QMDL file.
|
||||||
pub async fn new(qmdl_path: &str) -> Result<Self, String> {
|
pub async fn new(qmdl_path: &str, device: &Device) -> Result<Self, String> {
|
||||||
let mut df_cmd = Command::new("df");
|
// Uz801 needs to be told to use the busybox df specifically
|
||||||
|
let mut df_cmd: Command;
|
||||||
|
if matches!(device, Device::Uz801) {
|
||||||
|
df_cmd = Command::new("busybox");
|
||||||
|
df_cmd.arg("df");
|
||||||
|
} else {
|
||||||
|
df_cmd = Command::new("df");
|
||||||
|
}
|
||||||
df_cmd.arg("-h");
|
df_cmd.arg("-h");
|
||||||
df_cmd.arg(qmdl_path);
|
df_cmd.arg(qmdl_path);
|
||||||
let stdout = get_cmd_output(df_cmd).await?;
|
let stdout = get_cmd_output(df_cmd).await?;
|
||||||
let mut parts = stdout.split_whitespace().skip(7).to_owned();
|
|
||||||
|
// Handle standard df -h format
|
||||||
|
let mut parts = stdout.split_whitespace().skip(7);
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
partition: parts.next().ok_or("error parsing df output")?.to_string(),
|
partition: parts.next().ok_or("error parsing df output")?.to_string(),
|
||||||
total_size: parts.next().ok_or("error parsing df output")?.to_string(),
|
total_size: parts.next().ok_or("error parsing df output")?.to_string(),
|
||||||
@@ -83,9 +104,16 @@ async fn get_cmd_output(mut cmd: Command) -> Result<String, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl MemoryStats {
|
impl MemoryStats {
|
||||||
// runs "free -k" and parses the output to retrieve memory stats
|
// runs "free -k" and parses the output to retrieve memory stats for most devices,
|
||||||
pub async fn new() -> Result<Self, String> {
|
pub async fn new(device: &Device) -> Result<Self, String> {
|
||||||
let mut free_cmd = Command::new("free");
|
// Use busybox for Uz801
|
||||||
|
let mut free_cmd: Command;
|
||||||
|
if matches!(device, Device::Uz801) {
|
||||||
|
free_cmd = Command::new("busybox");
|
||||||
|
free_cmd.arg("free");
|
||||||
|
} else {
|
||||||
|
free_cmd = Command::new("free");
|
||||||
|
}
|
||||||
free_cmd.arg("-k");
|
free_cmd.arg("-k");
|
||||||
let stdout = get_cmd_output(free_cmd).await?;
|
let stdout = get_cmd_output(free_cmd).await?;
|
||||||
let mut numbers = stdout
|
let mut numbers = stdout
|
||||||
@@ -102,7 +130,7 @@ impl MemoryStats {
|
|||||||
// turns a number of kilobytes (like 28293) into a human-readable string (like "28.3M")
|
// turns a number of kilobytes (like 28293) into a human-readable string (like "28.3M")
|
||||||
fn humanize_kb(kb: usize) -> String {
|
fn humanize_kb(kb: usize) -> String {
|
||||||
if kb < 1000 {
|
if kb < 1000 {
|
||||||
return format!("{}K", kb);
|
return format!("{kb}K");
|
||||||
}
|
}
|
||||||
format!("{:.1}M", kb as f64 / 1024.0)
|
format!("{:.1}M", kb as f64 / 1024.0)
|
||||||
}
|
}
|
||||||
@@ -111,10 +139,10 @@ pub async fn get_system_stats(
|
|||||||
State(state): State<Arc<ServerState>>,
|
State(state): State<Arc<ServerState>>,
|
||||||
) -> Result<Json<SystemStats>, (StatusCode, String)> {
|
) -> Result<Json<SystemStats>, (StatusCode, String)> {
|
||||||
let qmdl_store = state.qmdl_store_lock.read().await;
|
let qmdl_store = state.qmdl_store_lock.read().await;
|
||||||
match SystemStats::new(qmdl_store.path.to_str().unwrap()).await {
|
match SystemStats::new(qmdl_store.path.to_str().unwrap(), &state.config.device).await {
|
||||||
Ok(stats) => Ok(Json(stats)),
|
Ok(stats) => Ok(Json(stats)),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
error!("error getting system stats: {}", err);
|
error!("error getting system stats: {err}");
|
||||||
Err((
|
Err((
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
"error getting system stats".to_string(),
|
"error getting system stats".to_string(),
|
||||||
@@ -140,3 +168,9 @@ pub async fn get_qmdl_manifest(
|
|||||||
current_entry,
|
current_entry,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_log() -> Result<String, (StatusCode, String)> {
|
||||||
|
tokio::fs::read_to_string("/data/rayhunter/rayhunter.log")
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
|
||||||
|
}
|
||||||
@@ -2,3 +2,6 @@
|
|||||||
package-lock.json
|
package-lock.json
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
yarn.lock
|
yarn.lock
|
||||||
|
|
||||||
|
# Static Assets
|
||||||
|
static/pico.min.css
|
||||||
15
daemon/web/.prettierrc
Normal file
15
daemon/web/.prettierrc
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 4,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 100,
|
||||||
|
"plugins": ["prettier-plugin-svelte"],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.svelte",
|
||||||
|
"options": {
|
||||||
|
"parser": "svelte"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
42
daemon/web/eslint.config.js
Normal file
42
daemon/web/eslint.config.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
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(
|
||||||
|
{
|
||||||
|
ignores: ['build/', '.svelte-kit/**', 'dist/'],
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'error',
|
||||||
|
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||||
|
],
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
38
daemon/web/package.json
Normal file
38
daemon/web/package.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "web",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build && gzip -9 ./build/index.html",
|
||||||
|
"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 .",
|
||||||
|
"fix": "eslint --fix ."
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-auto": "^3.0.0",
|
||||||
|
"@sveltejs/adapter-static": "^3.0.5",
|
||||||
|
"@sveltejs/kit": "^2.13.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
daemon/web/postcss.config.js
Normal file
6
daemon/web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
3
daemon/web/src/app.css
Normal file
3
daemon/web/src/app.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@import 'tailwindcss/base';
|
||||||
|
@import 'tailwindcss/components';
|
||||||
|
@import 'tailwindcss/utilities';
|
||||||
13
daemon/web/src/app.d.ts
vendored
Normal file
13
daemon/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
daemon/web/src/app.html
Normal file
12
daemon/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" style="width: 100%">
|
||||||
|
<div style="display: contents" class="m-4 xl:m-8">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
24
daemon/web/src/lib/action_errors.svelte.ts
Normal file
24
daemon/web/src/lib/action_errors.svelte.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
export class ActionError extends Error {
|
||||||
|
// The number of this an identical error has happened.
|
||||||
|
// This is shown as a number next to the error in the UI.
|
||||||
|
times = $state(1);
|
||||||
|
|
||||||
|
constructor(message: string, cause: Error) {
|
||||||
|
super(message);
|
||||||
|
this.cause = cause;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const action_errors: ActionError[] = $state([]);
|
||||||
|
|
||||||
|
export function add_error(e: Error, msg: string): void {
|
||||||
|
for (const existing of action_errors) {
|
||||||
|
if (existing.message === msg) {
|
||||||
|
existing.times += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const action_error = new ActionError(msg, e);
|
||||||
|
action_errors.unshift(action_error);
|
||||||
|
console.log(action_errors.length);
|
||||||
|
}
|
||||||
66
daemon/web/src/lib/analysis.svelte.spec.ts
Normal file
66
daemon/web/src/lib/analysis.svelte.spec.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { AnalysisRowType, parse_finished_report } from './analysis.svelte';
|
||||||
|
import { type NewlineDeliminatedJson } from './ndjson';
|
||||||
|
|
||||||
|
const SAMPLE_V2_REPORT_NDJSON: NewlineDeliminatedJson = [
|
||||||
|
{
|
||||||
|
analyzers: [
|
||||||
|
{
|
||||||
|
name: 'Analyzer 1',
|
||||||
|
description: 'A first analyzer',
|
||||||
|
version: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Analyzer 2',
|
||||||
|
description: 'A second analyzer',
|
||||||
|
version: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
report_version: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
skipped_message_reason: 'The reason why the message was skipped',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
packet_timestamp: '2024-08-19T03:33:54.318Z',
|
||||||
|
events: [
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
event_type: 'Low',
|
||||||
|
message: 'Something nasty happened',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('analysis report parsing', () => {
|
||||||
|
it('parses v2 example analysis', () => {
|
||||||
|
const report = parse_finished_report(SAMPLE_V2_REPORT_NDJSON);
|
||||||
|
expect(report.metadata.report_version).toEqual(2);
|
||||||
|
expect(report.metadata.analyzers).toEqual([
|
||||||
|
{
|
||||||
|
name: 'Analyzer 1',
|
||||||
|
description: 'A first analyzer',
|
||||||
|
version: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Analyzer 2',
|
||||||
|
description: 'A second analyzer',
|
||||||
|
version: 2,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(report.rows).toHaveLength(2);
|
||||||
|
expect(report.rows[0].type).toBe(AnalysisRowType.Skipped);
|
||||||
|
if (report.rows[1].type === AnalysisRowType.Analysis) {
|
||||||
|
const row = report.rows[1];
|
||||||
|
expect(row.events).toHaveLength(2);
|
||||||
|
expect(row.events[0]).toBeNull();
|
||||||
|
const event = row.events[1];
|
||||||
|
const expected_timestamp = new Date('2024-08-19T03:33:54.318Z');
|
||||||
|
expect(row.packet_timestamp.getTime()).toEqual(expected_timestamp.getTime());
|
||||||
|
expect(event!.event_type).toEqual('Low');
|
||||||
|
} else {
|
||||||
|
throw 'wrong row type';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
138
daemon/web/src/lib/analysis.svelte.ts
Normal file
138
daemon/web/src/lib/analysis.svelte.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
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 class ReportMetadata {
|
||||||
|
public analyzers: AnalyzerMetadata[];
|
||||||
|
public rayhunter: RayhunterMetadata;
|
||||||
|
public report_version: number;
|
||||||
|
|
||||||
|
constructor(ndjson: any) {
|
||||||
|
this.analyzers = ndjson.analyzers;
|
||||||
|
this.rayhunter = ndjson.rayhunter;
|
||||||
|
this.report_version = ndjson.report_version || 2; // Default to v2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RayhunterMetadata = {
|
||||||
|
rayhunter_version: string;
|
||||||
|
system_os: string;
|
||||||
|
arch: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AnalyzerMetadata = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
version: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AnalysisRow = SkippedPacket | PacketAnalysis;
|
||||||
|
export enum AnalysisRowType {
|
||||||
|
Skipped,
|
||||||
|
Analysis,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SkippedPacket = {
|
||||||
|
type: AnalysisRowType.Skipped;
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PacketAnalysis = {
|
||||||
|
type: AnalysisRowType.Analysis;
|
||||||
|
packet_timestamp: Date;
|
||||||
|
events: Event[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EventType = 'Informational' | 'Low' | 'Medium' | 'High';
|
||||||
|
|
||||||
|
export type Event = {
|
||||||
|
event_type: EventType;
|
||||||
|
message: string;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
function get_event(event_json: any): Event {
|
||||||
|
if (!['Informational', 'Low', 'Medium', 'High'].includes(event_json.event_type)) {
|
||||||
|
throw `Invalid/unhandled event type: ${event_json.event_type}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return event_json;
|
||||||
|
}
|
||||||
|
|
||||||
|
function get_rows(row_jsons: any[]): AnalysisRow[] {
|
||||||
|
const rows: AnalysisRow[] = [];
|
||||||
|
for (const row_json of row_jsons) {
|
||||||
|
if (row_json.skipped_message_reason) {
|
||||||
|
rows.push({
|
||||||
|
type: AnalysisRowType.Skipped,
|
||||||
|
reason: row_json.skipped_message_reason,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const events: Event[] = row_json.events.map((event_json: any): Event | null => {
|
||||||
|
if (event_json === null) {
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
return get_event(event_json);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
rows.push({
|
||||||
|
type: AnalysisRowType.Analysis,
|
||||||
|
packet_timestamp: new Date(row_json.packet_timestamp),
|
||||||
|
events,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function get_report_stats(rows: AnalysisRow[]): ReportStatistics {
|
||||||
|
let num_warnings = 0;
|
||||||
|
let num_informational_logs = 0;
|
||||||
|
let num_skipped_packets = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.type === AnalysisRowType.Skipped) {
|
||||||
|
num_skipped_packets++;
|
||||||
|
} else {
|
||||||
|
for (const event of row.events) {
|
||||||
|
if (event !== null) {
|
||||||
|
if (event.event_type === 'Informational') {
|
||||||
|
num_informational_logs++;
|
||||||
|
} else {
|
||||||
|
num_warnings++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
num_warnings,
|
||||||
|
num_informational_logs,
|
||||||
|
num_skipped_packets,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parse_finished_report(report_json: NewlineDeliminatedJson): AnalysisReport {
|
||||||
|
const metadata = new ReportMetadata(report_json[0]);
|
||||||
|
const rows = get_rows(report_json.slice(1));
|
||||||
|
const statistics = get_report_stats(rows);
|
||||||
|
return {
|
||||||
|
statistics,
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { get_report, type AnalysisReport } from "./analysis.svelte";
|
import { get_report, type AnalysisReport } from './analysis.svelte';
|
||||||
import type { Manifest, ManifestEntry } from "./manifest.svelte";
|
import { req } from './utils.svelte';
|
||||||
import { req } from "./utils.svelte";
|
|
||||||
|
|
||||||
export enum AnalysisStatus {
|
export enum AnalysisStatus {
|
||||||
// rayhunter is currently analyzing this entry (note that this is distinct
|
// rayhunter is currently analyzing this entry (note that this is distinct
|
||||||
@@ -19,16 +18,14 @@ type AnalysisStatusJson = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type AnalysisResult = {
|
export type AnalysisResult = {
|
||||||
name: string,
|
name: string;
|
||||||
status: AnalysisStatus,
|
status: AnalysisStatus;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class AnalysisManager {
|
export class AnalysisManager {
|
||||||
public status: Map<string, AnalysisStatus> = new Map();
|
public status: Map<string, AnalysisStatus> = $state(new Map());
|
||||||
public reports: Map<string, AnalysisReport | string> = new Map();
|
public reports: Map<string, AnalysisReport | string> = $state(new Map());
|
||||||
|
public set_queued_status(name: string) {
|
||||||
public async run_analysis(name: string) {
|
|
||||||
await req('POST', `/api/analysis/${name}`);
|
|
||||||
this.status.set(name, AnalysisStatus.Queued);
|
this.status.set(name, AnalysisStatus.Queued);
|
||||||
this.reports.delete(name);
|
this.reports.delete(name);
|
||||||
}
|
}
|
||||||
@@ -53,11 +50,13 @@ export class AnalysisManager {
|
|||||||
|
|
||||||
// fetch the analysis report
|
// fetch the analysis report
|
||||||
this.reports.delete(entry);
|
this.reports.delete(entry);
|
||||||
get_report(entry).then(report => {
|
get_report(entry)
|
||||||
this.reports.set(entry, report);
|
.then((report) => {
|
||||||
}).catch(err => {
|
this.reports.set(entry, report);
|
||||||
this.reports.set(entry, `Failed to get analysis: ${err}`);
|
})
|
||||||
});
|
.catch((err) => {
|
||||||
|
this.reports.set(entry, `Failed to get analysis: ${err}`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
100
daemon/web/src/lib/components/ActionErrors.svelte
Normal file
100
daemon/web/src/lib/components/ActionErrors.svelte
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { action_errors } from '../action_errors.svelte';
|
||||||
|
|
||||||
|
let pos = $state(0);
|
||||||
|
let current_error = $derived(action_errors[pos]);
|
||||||
|
|
||||||
|
function prev_error() {
|
||||||
|
if (pos > 0) pos -= 1;
|
||||||
|
else pos = action_errors.length - 1;
|
||||||
|
}
|
||||||
|
function next_error() {
|
||||||
|
if (pos + 1 < action_errors.length) pos += 1;
|
||||||
|
else pos = 0;
|
||||||
|
}
|
||||||
|
function clear_errors() {
|
||||||
|
pos = 0;
|
||||||
|
action_errors.length = 0;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if action_errors.length > 0}
|
||||||
|
<div
|
||||||
|
class="bg-red-100 border-red-100 drop-shadow p-4 flex flex-col gap-2
|
||||||
|
border rounded-md flex-1 justify-between fixed z-10 right-3 bottom-3 ml-3"
|
||||||
|
>
|
||||||
|
<div class="flex flex-row justify-between">
|
||||||
|
<span class="text-xl font-bold mb-2 mr-5 flex flex-row items-center gap-1 text-red-600">
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6 text-red-600"
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm11-4a1 1 0 1 0-2 0v5a1 1 0 1 0 2 0V8Zm-1 7a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H12Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Error Completing Action {current_error.times > 1 ? `x${current_error.times}` : ''}
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center mb-2">
|
||||||
|
{#if action_errors.length > 1}
|
||||||
|
<span>{pos + 1}/{action_errors.length}</span>
|
||||||
|
<button title="previous error" aria-label="previous error" onclick={prev_error}>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="m 15.499979,19.499979 -6.9999997,-7 6.9999997,-6.9999997"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button title="next error" aria-label="next error" onclick={next_error}>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="m 8.5000207,5.4999793 7.0000003,6.9999997 -7.0000003,7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button title="clear errors" aria-label="clear errors" onclick={clear_errors}>
|
||||||
|
<svg style="width:24px;height:24px" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
d="M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span>{current_error.message}</span>
|
||||||
|
{#if current_error.cause}
|
||||||
|
<details>
|
||||||
|
<summary>Details</summary>
|
||||||
|
<code>{current_error.cause}</code>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
92
daemon/web/src/lib/components/AnalysisStatus.svelte
Normal file
92
daemon/web/src/lib/components/AnalysisStatus.svelte
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { AnalysisStatus } from '$lib/analysisManager.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 {
|
||||||
|
return `${entry.analysis_report.statistics.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.by(() => {
|
||||||
|
if (!ready) {
|
||||||
|
return 'text-gray-700';
|
||||||
|
} else if ((entry.get_num_warnings() || 0) < 1) {
|
||||||
|
return 'text-green-700 border-green-500 bg-green-200 text-blue-600 border rounded-full px-2';
|
||||||
|
} else {
|
||||||
|
return 'text-red-700 border-red-500 bg-red-200 text-blue-600 border rounded-full px-2';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button class="flex flex-row gap-1 lg:gap-2" disabled={!ready} {onclick}>
|
||||||
|
<span class="flex flex-row items-center gap-1">
|
||||||
|
{#if entry.analysis_status === AnalysisStatus.Queued || entry.analysis_status === AnalysisStatus.Running || (entry.analysis_status === AnalysisStatus.Finished && entry.analysis_report === undefined)}
|
||||||
|
<svg
|
||||||
|
class="animate-spin h-4 w-4 text-blue-600"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
class="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
<span class={button_class}>{summary}</span>
|
||||||
|
</span>
|
||||||
|
<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>
|
||||||
102
daemon/web/src/lib/components/AnalysisTable.svelte
Normal file
102
daemon/web/src/lib/components/AnalysisTable.svelte
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { AnalysisRowType, type AnalysisReport } from '$lib/analysis.svelte';
|
||||||
|
let {
|
||||||
|
report,
|
||||||
|
}: {
|
||||||
|
report: AnalysisReport;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const date_formatter = new Intl.DateTimeFormat(undefined, {
|
||||||
|
timeStyle: 'long',
|
||||||
|
dateStyle: 'short',
|
||||||
|
});
|
||||||
|
|
||||||
|
const analyzers = report.metadata.analyzers;
|
||||||
|
|
||||||
|
const skipped_messages: Map<string, number> = $derived.by(() => {
|
||||||
|
let map = new Map();
|
||||||
|
for (const row of report.rows) {
|
||||||
|
if (row.type === AnalysisRowType.Skipped) {
|
||||||
|
let count = map.get(row.reason);
|
||||||
|
if (count === undefined) {
|
||||||
|
count = 0;
|
||||||
|
}
|
||||||
|
map.set(row.reason, 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}
|
||||||
|
<div class="overflow-x-scroll">
|
||||||
|
<table class="table-auto text-left">
|
||||||
|
<thead class="p-2">
|
||||||
|
<tr class="bg-gray-300">
|
||||||
|
<th class="p-2">Timestamp</th>
|
||||||
|
<th class="p-2">Heuristic</th>
|
||||||
|
<th class="p-2">Warning</th>
|
||||||
|
<th class="p-2">Severity</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each report.rows as row}
|
||||||
|
{#if row.type === AnalysisRowType.Analysis}
|
||||||
|
{@const parsed_date = new Date(row.packet_timestamp)}
|
||||||
|
{#each row.events as event, analyzerIndex}
|
||||||
|
{#if event !== null}
|
||||||
|
{@const analyzer = analyzers[analyzerIndex]}
|
||||||
|
{@const event_type_class = {
|
||||||
|
Informational: '',
|
||||||
|
Low: 'bg-yellow-200',
|
||||||
|
Medium: 'bg-orange-400',
|
||||||
|
High: 'bg-red-600',
|
||||||
|
}[event.event_type]}
|
||||||
|
<tr class="even:bg-gray-200 odd:bg-white">
|
||||||
|
<td class="p-2">{date_formatter.format(parsed_date)}</td>
|
||||||
|
<td class="p-2">{analyzer.name} v{analyzer.version}</td>
|
||||||
|
<td class="p-2">{event.message}</td>
|
||||||
|
<td class="p-2 {event_type_class} text-center"
|
||||||
|
>{event.event_type}</td
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/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>
|
||||||
|
<div class="overflow-x-scroll">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
53
daemon/web/src/lib/components/AnalysisView.svelte
Normal file
53
daemon/web/src/lib/components/AnalysisView.svelte
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { type ReportMetadata } from '$lib/analysis.svelte';
|
||||||
|
import type { ManifestEntry } from '$lib/manifest.svelte';
|
||||||
|
import { AnalysisManager } from '$lib/analysisManager.svelte';
|
||||||
|
import AnalysisTable from './AnalysisTable.svelte';
|
||||||
|
import ReAnalyzeButton from './ReAnalyzeButton.svelte';
|
||||||
|
let {
|
||||||
|
entry,
|
||||||
|
manager,
|
||||||
|
current,
|
||||||
|
}: {
|
||||||
|
entry: ManifestEntry;
|
||||||
|
manager: AnalysisManager;
|
||||||
|
current: boolean;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<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 !current}
|
||||||
|
<div class="flex flex-row justify-end items-center">
|
||||||
|
<ReAnalyzeButton {entry} {manager} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#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>
|
||||||
97
daemon/web/src/lib/components/ApiRequestButton.svelte
Normal file
97
daemon/web/src/lib/components/ApiRequestButton.svelte
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { user_action_req } from '$lib/utils.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
url,
|
||||||
|
method = 'POST',
|
||||||
|
label,
|
||||||
|
loadingLabel,
|
||||||
|
disabled = false,
|
||||||
|
variant = 'blue',
|
||||||
|
icon,
|
||||||
|
onclick,
|
||||||
|
ariaLabel,
|
||||||
|
errorMessage,
|
||||||
|
}: {
|
||||||
|
url: string;
|
||||||
|
method?: string;
|
||||||
|
label: string;
|
||||||
|
loadingLabel?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
variant?: 'blue' | 'red' | 'green';
|
||||||
|
icon?: any; // Svelte snippet
|
||||||
|
onclick?: () => void | Promise<void>;
|
||||||
|
ariaLabel?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let is_requesting = $state(false);
|
||||||
|
let is_disabled = $derived(disabled || is_requesting);
|
||||||
|
|
||||||
|
const variantClasses = {
|
||||||
|
blue: {
|
||||||
|
enabled: 'bg-blue-500 hover:bg-blue-700',
|
||||||
|
disabled: 'bg-blue-500 opacity-50 cursor-not-allowed',
|
||||||
|
},
|
||||||
|
red: {
|
||||||
|
enabled: 'bg-red-500 hover:bg-red-700',
|
||||||
|
disabled: 'bg-red-500 opacity-50 cursor-not-allowed',
|
||||||
|
},
|
||||||
|
green: {
|
||||||
|
enabled: 'bg-green-500 hover:bg-green-700',
|
||||||
|
disabled: 'bg-green-500 opacity-50 cursor-not-allowed',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handleClick() {
|
||||||
|
if (is_disabled) return;
|
||||||
|
|
||||||
|
is_requesting = true;
|
||||||
|
try {
|
||||||
|
await user_action_req(
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
errorMessage ? errorMessage : 'Error performing action'
|
||||||
|
);
|
||||||
|
if (onclick) {
|
||||||
|
await onclick();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to ${method} ${url}:`, err);
|
||||||
|
alert(`Request failed. Please try again.`);
|
||||||
|
} finally {
|
||||||
|
is_requesting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let buttonClasses = $derived(
|
||||||
|
is_disabled ? variantClasses[variant].disabled : variantClasses[variant].enabled
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="text-white font-bold py-2 px-2 sm:px-4 rounded-md flex flex-row items-center gap-1 {buttonClasses}"
|
||||||
|
onclick={handleClick}
|
||||||
|
disabled={is_disabled}
|
||||||
|
aria-label={ariaLabel || label}
|
||||||
|
>
|
||||||
|
<span>{is_requesting && loadingLabel ? loadingLabel : label}</span>
|
||||||
|
{#if is_requesting}
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 text-white animate-spin"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
{:else if icon}
|
||||||
|
{@render icon()}
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
282
daemon/web/src/lib/components/ConfigForm.svelte
Normal file
282
daemon/web/src/lib/components/ConfigForm.svelte
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
<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>
|
||||||
|
<label for="ntfy_url" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
ntfy URL for Sending Notifications
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ntfy_url"
|
||||||
|
type="url"
|
||||||
|
bind:value={config.ntfy_url}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
|
||||||
|
/>
|
||||||
|
</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 class="flex items-center">
|
||||||
|
<input
|
||||||
|
id="nas_null_cipher"
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={config.analyzers.nas_null_cipher}
|
||||||
|
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<label for="nas_null_cipher" class="ml-2 block text-sm text-gray-700">
|
||||||
|
NAS Null Cipher Heuristic
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
id="incomplete_sib"
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={config.analyzers.incomplete_sib}
|
||||||
|
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<label for="incomplete_sib" class="ml-2 block text-sm text-gray-700">
|
||||||
|
Incomplete SIB Heuristic
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
id="test_analyzer"
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={config.analyzers.test_analyzer}
|
||||||
|
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<label for="test_analyzer" class="ml-2 block text-sm text-gray-700">
|
||||||
|
Test Heuristic (noisey!)
|
||||||
|
</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>
|
||||||
12
daemon/web/src/lib/components/DeleteAllButton.svelte
Normal file
12
daemon/web/src/lib/components/DeleteAllButton.svelte
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import DeleteButton from './DeleteButton.svelte';
|
||||||
|
</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`}
|
||||||
|
name="all recodings"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user