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

|
||||
|
||||
# Rayhunter
|
||||
|
||||
```
|
||||
@@@@@@@ @@@@@@ @@@ @@@ @@@ @@@ @@@ @@@ @@@ @@@ @@@@@@@ @@@@@@@@ @@@@@@@
|
||||
@@! @@@ @@! @@@ @@! !@@ @@! @@@ @@! @@@ @@!@!@@@ @@! @@! @@! @@@
|
||||
@!@!!@! @!@!@!@! !@!@! @!@!@!@! @!@ !@! @!@@!!@! @!! @!!!:! @!@!!@!
|
||||
!!: :!! !!: !!! !!: !!: !!! !!: !!! !!: !!! !!: !!: !!: :!!
|
||||
: : : : : : .: : : : :.:: : :: : : : :: ::: : : :
|
||||
|
||||
|
||||
_ _ _ _ _ _ _ _
|
||||
)`'-.,_)`'-.,_)`'-.,_)`'-.,_)`'-.,_)`'-.,_)`'-.,_)`'-.,_
|
||||

|
||||
|
||||
O .
|
||||
O ' '
|
||||
o ' .
|
||||
o .'
|
||||
__________.-' '...___
|
||||
.-' ### '''...__
|
||||
/ a### ## ''--.._ ______
|
||||
'. # ######## ' .-'
|
||||
'-._ ..**********#### ___...---'''\ '
|
||||
'-._ __________...---''' \ l
|
||||
\ | apc '._|
|
||||
\__;
|
||||
```
|
||||

|
||||
|
||||
Rayhunter is an IMSI Catcher Catcher for the Orbic mobile hotspot.
|
||||
|
||||
**THIS CODE IS PROOF OF CONCEPT AND SHOULD NOT BE RELIED UPON IN HIGH RISK SITUATIONS**
|
||||
|
||||
Code is built and tested for the Orbic RC400L mobile hotspot, it may work on other orbics and other
|
||||
linux/qualcom devices but this is the only one we have tested on. Buy the orbic [using bezos bucks](https://www.amazon.com/gp/product/B09CLS6Z7X/)
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install the Android Debug Bridge (ADB) on your computer (don't worry about instructions for installing it on a phone/device yet). You can find instructions for doing so on your platform [here](https://www.xda-developers.com/install-adb-windows-macos-linux/#how-to-set-up-adb-on-your-computer).
|
||||
2. Download the latest [rayhunter release bundle](https://github.com/EFForg/rayhunter/releases) and extract it (on Windows use 7zip).
|
||||
3. Run the install script inside the bundle corresponding to your platform (`install-linux.sh`, `install-mac.sh`).
|
||||
4. Once finished, rayhunter should be running! You can verify this by visiting the web UI as described below.
|
||||
|
||||
## Usage
|
||||
|
||||
Once installed, rayhunter will run automatically whenever your Orbic device is running. It serves a web UI that provides some basic controls, such as being able to start/stop recordings, download captures, and view heuristic analyses of captures. You can access this UI in one of two ways:
|
||||
|
||||
1. Over wifi: Connect your phone/laptop to the Orbic's wifi network and visit `http://192.168.1.1:8080` (click past your browser warning you about the connection not being secure, rayhunter doesn't have HTTPS yet!)
|
||||
* Note that you'll need the Orbic's wifi password for this, which can be retrieved by pressing the "MENU" button on the device and opening the 2.4 GHz menu.
|
||||
2. Over usb: Connect the Orbic device to your laptop via usb. Run `adb forward tcp:8080 tcp:8080`, then visit `http://localhost:8080`.
|
||||
|
||||
## Development
|
||||
* Install ADB on your computer using the instructions above.
|
||||
|
||||
### If your are on x86 linux
|
||||
* on your linux laptop install rust the usual way and then install cross compiling dependences.
|
||||
* run `sudo apt install build-essential libc6-armhf-cross libc6-dev-armhf-cross gcc-arm-linux-gnueabihf`
|
||||
|
||||
* set up cross compliing for rust:
|
||||
```
|
||||
rustup target add x86_64-unknown-linux-gnu
|
||||
rustup target add armv7-unknown-linux-gnueabihf
|
||||
```
|
||||
|
||||
Now you can root your device and install rayhunter by running `./tools/install-dev.sh`
|
||||
|
||||
### If you are on windows or can't run the install scripts
|
||||
* Root your device on windows using the instructions here: https://xdaforums.com/t/resetting-verizon-orbic-speed-rc400l-firmware-flash-kajeet.4334899/#post-87855183
|
||||
|
||||
* Build for arm using `cargo build`
|
||||
|
||||
* Run tests using `cargo test_pc`
|
||||
|
||||
* Push the scripts in `scripts/` to /etc/init.d on device and make a directory called /data/rayhunter using `adb shell` (and sshell for your root shell if you followed the steps above)
|
||||
|
||||
* you also need to copy `config.toml.example` to /data/rayhunter/config.toml
|
||||
|
||||
* Then run `./make.sh` this will build the binary and push it over adb. Restart your device or run `/etc/init.d/rayhunter_daemon start` on the device and you are good to go.
|
||||
|
||||
* Write your code and write tests
|
||||
|
||||
* Build for arm using `cargo build`
|
||||
|
||||
* Run tests using `cargo test_pc`
|
||||
|
||||
* push to the device with `./make.sh`
|
||||
|
||||
## Documentation
|
||||
* Build docs locallly using `RUSTDOCFLAGS="--cfg docsrs" cargo doc --no-deps --all-features --open`
|
||||
|
||||
**LEGAL DISCLAIMER:** Use this program at your own risk. We beilieve running this program does not currently violate any laws or regulations in the United States. However, we are not responsible for civil or criminal liability resulting from the use of this software. If you are located outside of the US please consult with an attorney in your country to help you assess the legal risks of running this program.
|
||||
|
||||
*Good Hunting!*
|
||||
Rayhunter is an IMSI Catcher Catcher for the Orbic mobile hotspot. To learn more, check out the [Rayhunter Book](https://efforg.github.io/rayhunter/).
|
||||
|
||||
5
SECURITY.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Security vulnerabilities can be reported using GitHub's [private vulnerability reporting tool](https://github.com/EFForg/rayhunter/security/advisories/new).
|
||||
@@ -1,34 +0,0 @@
|
||||
[package]
|
||||
name = "rayhunter-daemon"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "rayhunter-daemon"
|
||||
path = "src/daemon.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "rayhunter-check"
|
||||
path = "src/check.rs"
|
||||
|
||||
[dependencies]
|
||||
rayhunter = { path = "../lib" }
|
||||
toml = "0.8.8"
|
||||
serde = { version = "1.0.193", features = ["derive"] }
|
||||
tokio = { version = "1.35.1", features = ["full"] }
|
||||
axum = "0.7.3"
|
||||
futures-core = "0.3.30"
|
||||
thiserror = "1.0.52"
|
||||
log = "0.4.20"
|
||||
env_logger = "0.10.1"
|
||||
tokio-util = { version = "0.7.10", features = ["rt"] }
|
||||
futures-macro = "0.3.30"
|
||||
include_dir = "0.7.3"
|
||||
mime_guess = "2.0.4"
|
||||
tempdir = "0.3.7"
|
||||
chrono = { version = "0.4.31", features = ["serde"] }
|
||||
tokio-stream = "0.1.14"
|
||||
futures = "0.3.30"
|
||||
clap = { version = "4.5.2", features = ["derive"] }
|
||||
serde_json = "1.0.114"
|
||||
image = "0.25.1"
|
||||
@@ -1,31 +0,0 @@
|
||||
use std::{future, path::PathBuf, pin::pin};
|
||||
use rayhunter::{analysis::analyzer::Harness, diag::DataType, qmdl::QmdlReader};
|
||||
use tokio::fs::File;
|
||||
use clap::Parser;
|
||||
use futures::TryStreamExt;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version, about)]
|
||||
struct Args {
|
||||
#[arg(short, long)]
|
||||
qmdl_path: PathBuf,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
env_logger::init();
|
||||
let args = Args::parse();
|
||||
|
||||
let mut harness = Harness::new_with_all_analyzers();
|
||||
|
||||
let qmdl_file = File::open(args.qmdl_path).await.expect("failed to open QMDL file");
|
||||
let file_size = qmdl_file.metadata().await.expect("failed to get QMDL file metadata").len();
|
||||
let mut qmdl_reader = QmdlReader::new(qmdl_file, Some(file_size as usize));
|
||||
let mut qmdl_stream = pin!(qmdl_reader.as_stream()
|
||||
.try_filter(|container| future::ready(container.data_type == DataType::UserSpace)));
|
||||
println!("{}\n", serde_json::to_string(&harness.get_metadata()).expect("failed to serialize report metadata"));
|
||||
while let Some(container) = qmdl_stream.try_next().await.expect("failed getting QMDL container") {
|
||||
let row = harness.analyze_qmdl_messages(container);
|
||||
println!("{}\n", serde_json::to_string(&row).expect("failed to serialize row"));
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
use crate::error::RayhunterError;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ConfigFile {
|
||||
qmdl_store_path: Option<String>,
|
||||
port: Option<u16>,
|
||||
readonly_mode: Option<bool>,
|
||||
ui_level: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Config {
|
||||
pub qmdl_store_path: String,
|
||||
pub port: u16,
|
||||
pub readonly_mode: bool,
|
||||
pub ui_level: u8,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Config {
|
||||
qmdl_store_path: "/data/rayhunter/qmdl".to_string(),
|
||||
port: 8080,
|
||||
readonly_mode: false,
|
||||
ui_level: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_config<P>(path: P) -> Result<Config, RayhunterError> where P: AsRef<std::path::Path> {
|
||||
let mut config = Config::default();
|
||||
if let Ok(config_file) = std::fs::read_to_string(&path) {
|
||||
let parsed_config: ConfigFile = toml::from_str(&config_file)
|
||||
.map_err(RayhunterError::ConfigFileParsingError)?;
|
||||
if let Some(path) = parsed_config.qmdl_store_path { config.qmdl_store_path = path }
|
||||
if let Some(port) = parsed_config.port { config.port = port }
|
||||
if let Some(readonly_mode) = parsed_config.readonly_mode { config.readonly_mode = readonly_mode }
|
||||
if let Some(ui_level) = parsed_config.ui_level { config.ui_level = ui_level }
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub struct Args {
|
||||
pub config_path: String,
|
||||
}
|
||||
|
||||
pub fn parse_args() -> Args {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if args.len() != 2 {
|
||||
println!("Usage: {} /path/to/config/file", args[0]);
|
||||
std::process::exit(1);
|
||||
}
|
||||
Args {
|
||||
config_path: args[1].clone(),
|
||||
}
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
mod config;
|
||||
mod error;
|
||||
mod pcap;
|
||||
mod server;
|
||||
mod stats;
|
||||
mod qmdl_store;
|
||||
mod diag;
|
||||
mod framebuffer;
|
||||
|
||||
use crate::config::{parse_config, parse_args};
|
||||
use crate::diag::run_diag_read_thread;
|
||||
use crate::qmdl_store::RecordingStore;
|
||||
use crate::server::{ServerState, get_qmdl, serve_static};
|
||||
use crate::pcap::get_pcap;
|
||||
use crate::stats::get_system_stats;
|
||||
use crate::error::RayhunterError;
|
||||
use crate::framebuffer::Framebuffer;
|
||||
|
||||
use axum::response::Redirect;
|
||||
use diag::{get_analysis_report, start_recording, stop_recording, DiagDeviceCtrlMessage};
|
||||
use log::{info, error};
|
||||
use rayhunter::diag_device::DiagDevice;
|
||||
use axum::routing::{get, post};
|
||||
use axum::Router;
|
||||
use stats::get_qmdl_manifest;
|
||||
use tokio::sync::mpsc::{self, Sender};
|
||||
use tokio::sync::oneshot::error::TryRecvError;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_util::task::TaskTracker;
|
||||
use std::net::SocketAddr;
|
||||
use std::thread::sleep;
|
||||
use std::time::Duration;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::{RwLock, oneshot};
|
||||
use std::sync::Arc;
|
||||
use include_dir::{include_dir, Dir};
|
||||
|
||||
// Runs the axum server, taking all the elements needed to build up our
|
||||
// ServerState and a oneshot Receiver that'll fire when it's time to shutdown
|
||||
// (i.e. user hit ctrl+c)
|
||||
async fn run_server(
|
||||
task_tracker: &TaskTracker,
|
||||
config: &config::Config,
|
||||
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
|
||||
server_shutdown_rx: oneshot::Receiver<()>,
|
||||
diag_device_sender: Sender<DiagDeviceCtrlMessage>
|
||||
) -> JoinHandle<()> {
|
||||
let state = Arc::new(ServerState {
|
||||
qmdl_store_lock,
|
||||
diag_device_ctrl_sender: diag_device_sender,
|
||||
readonly_mode: config.readonly_mode
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route("/api/pcap/*name", get(get_pcap))
|
||||
.route("/api/qmdl/*name", get(get_qmdl))
|
||||
.route("/api/system-stats", get(get_system_stats))
|
||||
.route("/api/qmdl-manifest", get(get_qmdl_manifest))
|
||||
.route("/api/start-recording", post(start_recording))
|
||||
.route("/api/stop-recording", post(stop_recording))
|
||||
.route("/api/analysis-report", get(get_analysis_report))
|
||||
.route("/", get(|| async { Redirect::permanent("/index.html") }))
|
||||
.route("/*path", get(serve_static))
|
||||
.with_state(state);
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], config.port));
|
||||
let listener = TcpListener::bind(&addr).await.unwrap();
|
||||
task_tracker.spawn(async move {
|
||||
info!("The orca is hunting for stingrays...");
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(server_shutdown_signal(server_shutdown_rx))
|
||||
.await.unwrap();
|
||||
})
|
||||
}
|
||||
|
||||
async fn server_shutdown_signal(server_shutdown_rx: oneshot::Receiver<()>) {
|
||||
server_shutdown_rx.await.unwrap();
|
||||
info!("Server received shutdown signal, exiting...");
|
||||
}
|
||||
|
||||
// Loads a QmdlStore if one exists, and if not, only create one if we're not in
|
||||
// readonly mode.
|
||||
async fn init_qmdl_store(config: &config::Config) -> Result<RecordingStore, RayhunterError> {
|
||||
match (RecordingStore::exists(&config.qmdl_store_path).await?, config.readonly_mode) {
|
||||
(true, _) => Ok(RecordingStore::load(&config.qmdl_store_path).await?),
|
||||
(false, false) => Ok(RecordingStore::create(&config.qmdl_store_path).await?),
|
||||
(false, true) => Err(RayhunterError::NoStoreReadonlyMode(config.qmdl_store_path.clone())),
|
||||
}
|
||||
}
|
||||
|
||||
// Start a thread that'll track when user hits ctrl+c. When that happens,
|
||||
// trigger various cleanup tasks, including sending signals to other threads to
|
||||
// shutdown
|
||||
fn run_ctrl_c_thread(
|
||||
task_tracker: &TaskTracker,
|
||||
diag_device_sender: Sender<DiagDeviceCtrlMessage>,
|
||||
server_shutdown_tx: oneshot::Sender<()>,
|
||||
ui_shutdown_tx: oneshot::Sender<()>,
|
||||
qmdl_store_lock: Arc<RwLock<RecordingStore>>
|
||||
) -> JoinHandle<Result<(), RayhunterError>> {
|
||||
task_tracker.spawn(async move {
|
||||
match tokio::signal::ctrl_c().await {
|
||||
Ok(()) => {
|
||||
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||
if qmdl_store.current_entry.is_some() {
|
||||
info!("Closing current QMDL entry...");
|
||||
qmdl_store.close_current_entry().await?;
|
||||
info!("Done!");
|
||||
}
|
||||
|
||||
server_shutdown_tx.send(())
|
||||
.expect("couldn't send server shutdown signal");
|
||||
info!("sending UI shutdown");
|
||||
ui_shutdown_tx.send(())
|
||||
.expect("couldn't send ui shutdown signal");
|
||||
diag_device_sender.send(DiagDeviceCtrlMessage::Exit).await
|
||||
.expect("couldn't send Exit message to diag thread");
|
||||
},
|
||||
Err(err) => {
|
||||
error!("Unable to listen for shutdown signal: {}", err);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
async fn update_ui(task_tracker: &TaskTracker, config: &config::Config, mut ui_shutdown_rx: oneshot::Receiver<()>){
|
||||
static IMAGE_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/static/images/");
|
||||
let display_level = config.ui_level;
|
||||
if display_level == 0 {
|
||||
info!("Invisible mode, not spawning UI.");
|
||||
}
|
||||
|
||||
task_tracker.spawn_blocking(move || {
|
||||
let mut fb: Framebuffer = Framebuffer::new();
|
||||
// this feels wrong, is there a more rusty way to do this?
|
||||
let mut img: Option<&[u8]> = None;
|
||||
if display_level == 2 {
|
||||
img = Some(IMAGE_DIR.get_file("orca.gif").expect("failed to read orca.gif").contents());
|
||||
} else if display_level == 3 {
|
||||
img = Some(IMAGE_DIR.get_file("eff.png").expect("failed to read eff.png").contents());
|
||||
}
|
||||
loop {
|
||||
match ui_shutdown_rx.try_recv() {
|
||||
Ok(_) => {
|
||||
info!("received UI shutdown");
|
||||
break;
|
||||
},
|
||||
Err(TryRecvError::Empty) => {},
|
||||
Err(e) => panic!("error receiving shutdown message: {e}")
|
||||
|
||||
}
|
||||
match display_level {
|
||||
2 => {
|
||||
fb.draw_gif(img.unwrap());
|
||||
},
|
||||
3 => {
|
||||
fb.draw_img(img.unwrap())
|
||||
},
|
||||
128 => {
|
||||
fb.draw_line(framebuffer::Color565::Cyan, 128);
|
||||
fb.draw_line(framebuffer::Color565::Pink, 102);
|
||||
fb.draw_line(framebuffer::Color565::White, 76);
|
||||
fb.draw_line(framebuffer::Color565::Pink, 50);
|
||||
fb.draw_line(framebuffer::Color565::Cyan, 25);
|
||||
},
|
||||
1 | _ => {
|
||||
fb.draw_line(framebuffer::Color565::Green, 2);
|
||||
},
|
||||
};
|
||||
sleep(Duration::from_millis(100));
|
||||
}
|
||||
}).await.unwrap();
|
||||
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), RayhunterError> {
|
||||
env_logger::init();
|
||||
|
||||
let args = parse_args();
|
||||
let config = parse_config(&args.config_path)?;
|
||||
|
||||
// TaskTrackers give us an interface to spawn tokio threads, and then
|
||||
// eventually await all of them ending
|
||||
let task_tracker = TaskTracker::new();
|
||||
|
||||
let qmdl_store_lock = Arc::new(RwLock::new(init_qmdl_store(&config).await?));
|
||||
let (tx, rx) = mpsc::channel::<DiagDeviceCtrlMessage>(1);
|
||||
if !config.readonly_mode {
|
||||
let mut dev = DiagDevice::new().await
|
||||
.map_err(RayhunterError::DiagInitError)?;
|
||||
dev.config_logs().await
|
||||
.map_err(RayhunterError::DiagInitError)?;
|
||||
|
||||
run_diag_read_thread(&task_tracker, dev, rx, qmdl_store_lock.clone());
|
||||
}
|
||||
let (ui_shutdown_tx, ui_shutdown_rx) = oneshot::channel();
|
||||
let (server_shutdown_tx, server_shutdown_rx) = oneshot::channel::<()>();
|
||||
run_ctrl_c_thread(&task_tracker, tx.clone(), server_shutdown_tx, ui_shutdown_tx, qmdl_store_lock.clone());
|
||||
run_server(&task_tracker, &config, qmdl_store_lock.clone(), server_shutdown_rx, tx).await;
|
||||
update_ui(&task_tracker, &config, ui_shutdown_rx).await;
|
||||
|
||||
task_tracker.close();
|
||||
task_tracker.wait().await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
205
bin/src/diag.rs
@@ -1,205 +0,0 @@
|
||||
use std::pin::pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::State;
|
||||
use axum::http::header::CONTENT_TYPE;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use rayhunter::analysis::analyzer::Harness;
|
||||
use rayhunter::diag::{DataType, MessagesContainer};
|
||||
use rayhunter::diag_device::DiagDevice;
|
||||
use serde::Serialize;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use rayhunter::qmdl::QmdlWriter;
|
||||
use log::{debug, error, info};
|
||||
use tokio::fs::File;
|
||||
use tokio::io::{BufWriter, AsyncWriteExt};
|
||||
use tokio_util::io::ReaderStream;
|
||||
use tokio_util::task::TaskTracker;
|
||||
use futures::{StreamExt, TryStreamExt};
|
||||
|
||||
use crate::qmdl_store::RecordingStore;
|
||||
use crate::server::ServerState;
|
||||
|
||||
pub enum DiagDeviceCtrlMessage {
|
||||
StopRecording,
|
||||
StartRecording((QmdlWriter<File>, File)),
|
||||
Exit,
|
||||
}
|
||||
|
||||
struct AnalysisWriter {
|
||||
writer: BufWriter<File>,
|
||||
harness: Harness,
|
||||
bytes_written: usize,
|
||||
}
|
||||
|
||||
// We write our analysis results to a file immediately to minimize the amount of
|
||||
// state Rayhunter has to keep track of in memory. The analysis file's format is
|
||||
// Newline Delimited JSON
|
||||
// (https://docs.mulesoft.com/dataweave/latest/dataweave-formats-ndjson), which
|
||||
// lets us simply append new rows to the end without parsing the entire JSON
|
||||
// object beforehand.
|
||||
impl AnalysisWriter {
|
||||
pub async fn new(file: File) -> Result<Self, std::io::Error> {
|
||||
let mut result = Self {
|
||||
writer: BufWriter::new(file),
|
||||
harness: Harness::new_with_all_analyzers(),
|
||||
bytes_written: 0,
|
||||
};
|
||||
let metadata = result.harness.get_metadata();
|
||||
result.write(&metadata).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// Runs the analysis harness on the given container, serializing the results
|
||||
// to the analysis file and returning the file's new length.
|
||||
pub async fn analyze(&mut self, container: MessagesContainer) -> Result<usize, std::io::Error> {
|
||||
let row = self.harness.analyze_qmdl_messages(container);
|
||||
if !row.is_empty() {
|
||||
self.write(&row).await?;
|
||||
}
|
||||
Ok(self.bytes_written)
|
||||
}
|
||||
|
||||
async fn write<T: Serialize>(&mut self, value: &T) -> Result<(), std::io::Error> {
|
||||
let mut value_str = serde_json::to_string(value).unwrap();
|
||||
value_str.push('\n');
|
||||
self.bytes_written += value_str.len();
|
||||
self.writer.write_all(value_str.as_bytes()).await?;
|
||||
self.writer.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Flushes any pending I/O to disk before dropping the writer
|
||||
pub async fn close(mut self) -> Result<(), std::io::Error> {
|
||||
self.writer.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_diag_read_thread(
|
||||
task_tracker: &TaskTracker,
|
||||
mut dev: DiagDevice,
|
||||
mut qmdl_file_rx: Receiver<DiagDeviceCtrlMessage>,
|
||||
qmdl_store_lock: Arc<RwLock<RecordingStore>>
|
||||
) {
|
||||
task_tracker.spawn(async move {
|
||||
let (initial_qmdl_file, initial_analysis_file) = qmdl_store_lock.write().await.new_entry().await.expect("failed creating QMDL file entry");
|
||||
let mut maybe_qmdl_writer: Option<QmdlWriter<File>> = Some(QmdlWriter::new(initial_qmdl_file));
|
||||
let mut diag_stream = pin!(dev.as_stream().into_stream());
|
||||
let mut maybe_analysis_writer = Some(AnalysisWriter::new(initial_analysis_file).await
|
||||
.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).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_file_len = analysis_writer.analyze(container).await
|
||||
.expect("failed to analyze container");
|
||||
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||
let index = qmdl_store.current_entry.expect("DiagDevice had qmdl_writer, but QmdlStore didn't have current entry???");
|
||||
qmdl_store.update_entry_analysis_size(index, analysis_file_len as usize).await
|
||||
.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.readonly_mode {
|
||||
return Err((StatusCode::FORBIDDEN, "server is in readonly 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)))?;
|
||||
Ok((StatusCode::ACCEPTED, "ok".to_string()))
|
||||
}
|
||||
|
||||
pub async fn stop_recording(State(state): State<Arc<ServerState>>) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||
if state.readonly_mode {
|
||||
return Err((StatusCode::FORBIDDEN, "server is in readonly mode".to_string()));
|
||||
}
|
||||
let mut qmdl_store = state.qmdl_store_lock.write().await;
|
||||
qmdl_store.close_current_entry().await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("couldn't close current qmdl entry: {}", e)))?;
|
||||
state.diag_device_ctrl_sender.send(DiagDeviceCtrlMessage::StopRecording).await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("couldn't send stop recording message: {}", e)))?;
|
||||
Ok((StatusCode::ACCEPTED, "ok".to_string()))
|
||||
}
|
||||
|
||||
pub async fn get_analysis_report(State(state): State<Arc<ServerState>>) -> Result<Response, (StatusCode, String)> {
|
||||
let qmdl_store = state.qmdl_store_lock.read().await;
|
||||
let Some(entry) = qmdl_store.get_current_entry() else {
|
||||
return Err((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"No QMDL data's being recorded to analyze, try starting a new recording!".to_string()
|
||||
));
|
||||
};
|
||||
let analysis_file = qmdl_store.open_entry_analysis(entry).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,92 +0,0 @@
|
||||
use image::{codecs::gif::GifDecoder, imageops::FilterType, AnimationDecoder, DynamicImage};
|
||||
use std::{io::Cursor, time::Duration};
|
||||
|
||||
const FB_PATH:&str = "/dev/fb0";
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
// TODO actually poll for this, maybe w/ fbset?
|
||||
struct Dimensions {
|
||||
height: u32,
|
||||
width: u32,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub enum Color565 {
|
||||
Red = 0b1111100000000000,
|
||||
Green = 0b0000011111100000,
|
||||
Blue = 0b0000000000011111,
|
||||
White = 0b1111111111111111,
|
||||
Black = 0b0000000000000000,
|
||||
Cyan = 0b0000011111111111,
|
||||
Yellow = 0b1111111111100000,
|
||||
Pink = 0b1111010010011111,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct Framebuffer<'a> {
|
||||
dimensions: Dimensions,
|
||||
path: &'a str,
|
||||
}
|
||||
|
||||
impl Framebuffer<'_>{
|
||||
pub const fn new() -> Self {
|
||||
Framebuffer{
|
||||
dimensions: Dimensions{height: 128, width: 128},
|
||||
path: FB_PATH,
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&mut self, img: DynamicImage) {
|
||||
let mut width = img.width();
|
||||
let mut height = img.height();
|
||||
let resized_img: DynamicImage;
|
||||
if height > self.dimensions.height ||
|
||||
width > self.dimensions.width {
|
||||
resized_img = img.resize( self.dimensions.width, self.dimensions.height, FilterType::CatmullRom);
|
||||
width = self.dimensions.width.min(resized_img.width());
|
||||
height = self.dimensions.height.min(resized_img.height());
|
||||
} else {
|
||||
resized_img = img;
|
||||
}
|
||||
let img_rgba8 = resized_img.as_rgba8().unwrap();
|
||||
let mut buf = Vec::new();
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let px = img_rgba8.get_pixel(x, y);
|
||||
let mut rgb565: u16 = (px[0] as u16 & 0b11111000) << 8;
|
||||
rgb565 |= (px[1] as u16 & 0b11111100) << 3;
|
||||
rgb565 |= (px[2] as u16) >> 3;
|
||||
buf.extend(rgb565.to_le_bytes());
|
||||
}
|
||||
}
|
||||
std::fs::write(self.path, &buf).unwrap();
|
||||
}
|
||||
|
||||
pub fn draw_gif(&mut self, img_buffer: &[u8]) {
|
||||
// this is dumb and i'm sure there's a better way to loop this
|
||||
let cursor = Cursor::new(img_buffer);
|
||||
let decoder = GifDecoder::new(cursor).unwrap();
|
||||
for maybe_frame in decoder.into_frames() {
|
||||
let frame = maybe_frame.unwrap();
|
||||
let (numerator, _) = frame.delay().numer_denom_ms();
|
||||
let img = DynamicImage::from(frame.into_buffer());
|
||||
self.write(img);
|
||||
std::thread::sleep(Duration::from_millis(numerator as u64));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw_img(&mut self, img_buffer: &[u8]) {
|
||||
let img = image::load_from_memory(img_buffer).unwrap();
|
||||
self.write(img);
|
||||
}
|
||||
|
||||
pub fn draw_line(&mut self, color: Color565, height: u32){
|
||||
let px_num= height * self.dimensions.width;
|
||||
let color: u16 = color as u16;
|
||||
let mut buffer: Vec<u8> = Vec::new();
|
||||
for _ in 0..px_num {
|
||||
buffer.extend(color.to_le_bytes());
|
||||
}
|
||||
std::fs::write(self.path, &buffer).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
use crate::ServerState;
|
||||
|
||||
use rayhunter::diag::DataType;
|
||||
use rayhunter::gsmtap_parser;
|
||||
use rayhunter::pcap::GsmtapPcapWriter;
|
||||
use rayhunter::qmdl::QmdlReader;
|
||||
use axum::body::Body;
|
||||
use axum::http::header::CONTENT_TYPE;
|
||||
use axum::extract::{State, Path};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{Response, IntoResponse};
|
||||
use tokio::io::duplex;
|
||||
use tokio_util::io::ReaderStream;
|
||||
use std::{future, pin::pin};
|
||||
use std::sync::Arc;
|
||||
use log::error;
|
||||
use futures::TryStreamExt;
|
||||
|
||||
// Streams a pcap file chunk-by-chunk to the client by reading the QMDL data
|
||||
// written so far. This is done by spawning a thread which streams chunks of
|
||||
// pcap data to a channel that's piped to the client.
|
||||
pub async fn get_pcap(State(state): State<Arc<ServerState>>, Path(qmdl_name): Path<String>) -> Result<Response, (StatusCode, String)> {
|
||||
let qmdl_store = state.qmdl_store_lock.read().await;
|
||||
let entry = qmdl_store.entry_for_name(&qmdl_name)
|
||||
.ok_or((StatusCode::NOT_FOUND, format!("couldn't find qmdl file with name {}", qmdl_name)))?;
|
||||
if entry.qmdl_size_bytes == 0 {
|
||||
return Err((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"QMDL file is empty, try again in a bit!".to_string()
|
||||
));
|
||||
}
|
||||
|
||||
let qmdl_file = qmdl_store.open_entry_qmdl(&entry).await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)))?;
|
||||
// the QMDL reader should stop at the last successfully written data chunk
|
||||
// (entry.size_bytes)
|
||||
let (reader, writer) = duplex(1024);
|
||||
let mut pcap_writer = GsmtapPcapWriter::new(writer).await.unwrap();
|
||||
pcap_writer.write_iface_header().await.unwrap();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut reader = QmdlReader::new(qmdl_file, Some(entry.qmdl_size_bytes));
|
||||
let mut messages_stream = pin!(reader.as_stream()
|
||||
.try_filter(|container| future::ready(container.data_type == DataType::UserSpace)));
|
||||
|
||||
while let Some(container) = messages_stream.try_next().await.expect("failed getting QMDL container") {
|
||||
for maybe_msg in container.into_messages() {
|
||||
match maybe_msg {
|
||||
Ok(msg) => {
|
||||
let maybe_gsmtap_msg = gsmtap_parser::parse(msg)
|
||||
.expect("error parsing gsmtap message");
|
||||
if let Some((timestamp, gsmtap_msg)) = maybe_gsmtap_msg {
|
||||
pcap_writer.write_gsmtap_message(gsmtap_msg, timestamp).await
|
||||
.expect("error writing pcap packet");
|
||||
}
|
||||
},
|
||||
Err(e) => error!("error parsing message: {:?}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let headers = [(CONTENT_TYPE, "application/vnd.tcpdump.pcap")];
|
||||
let body = Body::from_stream(ReaderStream::new(reader));
|
||||
Ok((headers, body).into_response())
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
use std::path::{PathBuf, Path};
|
||||
use thiserror::Error;
|
||||
use tokio::{fs::{self, File, try_exists}, io::AsyncWriteExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use chrono::{DateTime, Local};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum RecordingStoreError {
|
||||
#[error("Can't close an entry when there's no current entry")]
|
||||
NoCurrentEntry,
|
||||
#[error("Couldn't create file: {0}")]
|
||||
CreateFileError(tokio::io::Error),
|
||||
#[error("Couldn't read file: {0}")]
|
||||
ReadFileError(tokio::io::Error),
|
||||
#[error("Couldn't open directory at path: {0}")]
|
||||
OpenDirError(tokio::io::Error),
|
||||
#[error("Couldn't read manifest file: {0}")]
|
||||
ReadManifestError(tokio::io::Error),
|
||||
#[error("Couldn't write manifest file: {0}")]
|
||||
WriteManifestError(tokio::io::Error),
|
||||
#[error("Couldn't parse QMDL store manifest file: {0}")]
|
||||
ParseManifestError(toml::de::Error)
|
||||
}
|
||||
|
||||
pub struct RecordingStore {
|
||||
pub path: PathBuf,
|
||||
pub manifest: Manifest,
|
||||
pub current_entry: Option<usize>, // index into manifest
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, PartialEq, Debug)]
|
||||
pub struct Manifest {
|
||||
pub entries: Vec<ManifestEntry>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, PartialEq, Debug)]
|
||||
pub struct ManifestEntry {
|
||||
pub name: String,
|
||||
pub start_time: DateTime<Local>,
|
||||
pub last_message_time: Option<DateTime<Local>>,
|
||||
pub qmdl_size_bytes: usize,
|
||||
pub analysis_size_bytes: usize,
|
||||
}
|
||||
|
||||
impl ManifestEntry {
|
||||
fn new() -> Self {
|
||||
let now = Local::now();
|
||||
ManifestEntry {
|
||||
name: format!("{}", now.timestamp()),
|
||||
start_time: now,
|
||||
last_message_time: None,
|
||||
qmdl_size_bytes: 0,
|
||||
analysis_size_bytes: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_qmdl_filepath<P: AsRef<Path>>(&self, path: P) -> PathBuf {
|
||||
let mut filepath = path.as_ref().join(&self.name);
|
||||
filepath.set_extension("qmdl");
|
||||
filepath
|
||||
}
|
||||
|
||||
pub fn get_analysis_filepath<P: AsRef<Path>>(&self, path: P) -> PathBuf {
|
||||
let mut filepath = path.as_ref().join(&self.name);
|
||||
filepath.set_extension("ndjson");
|
||||
filepath
|
||||
}
|
||||
}
|
||||
|
||||
impl RecordingStore {
|
||||
// Returns whether a directory with a "manifest.toml" exists at the given
|
||||
// path (though doesn't check if that manifest is valid)
|
||||
pub async fn exists<P>(path: P) -> Result<bool, RecordingStoreError> where P: AsRef<Path> {
|
||||
let manifest_path = path.as_ref().join("manifest.toml");
|
||||
let dir_exists = try_exists(path).await.map_err(RecordingStoreError::OpenDirError)?;
|
||||
let manifest_exists = try_exists(manifest_path).await.map_err(RecordingStoreError::ReadManifestError)?;
|
||||
Ok(dir_exists && manifest_exists)
|
||||
}
|
||||
|
||||
// Loads an existing RecordingStore at the given path. Errors if no store exists,
|
||||
// or if it's malformed.
|
||||
pub async fn load<P>(path: P) -> Result<Self, RecordingStoreError> where P: AsRef<Path> {
|
||||
let path: PathBuf = path.as_ref().to_path_buf();
|
||||
let manifest = RecordingStore::read_manifest(&path).await?;
|
||||
Ok(RecordingStore {
|
||||
path,
|
||||
manifest,
|
||||
current_entry: None,
|
||||
})
|
||||
}
|
||||
|
||||
// Creates a new RecordingStore at the given path. This involves creating a dir
|
||||
// and writing an empty manifest.
|
||||
pub async fn create<P>(path: P) -> Result<Self, RecordingStoreError> where P: AsRef<Path> {
|
||||
let manifest_path = path.as_ref().join("manifest.toml");
|
||||
fs::create_dir_all(&path).await
|
||||
.map_err(RecordingStoreError::OpenDirError)?;
|
||||
let mut manifest_file = File::create(&manifest_path).await
|
||||
.map_err(RecordingStoreError::WriteManifestError)?;
|
||||
let empty_manifest = Manifest { entries: Vec::new() };
|
||||
let empty_manifest_contents = toml::to_string_pretty(&empty_manifest)
|
||||
.expect("failed to serialize manifest");
|
||||
manifest_file.write_all(empty_manifest_contents.as_bytes()).await
|
||||
.map_err(RecordingStoreError::WriteManifestError)?;
|
||||
RecordingStore::load(path).await
|
||||
}
|
||||
|
||||
async fn read_manifest<P>(path: P) -> Result<Manifest, RecordingStoreError> where P: AsRef<Path> {
|
||||
let manifest_path = path.as_ref().join("manifest.toml");
|
||||
let file_contents = fs::read_to_string(&manifest_path).await
|
||||
.map_err(RecordingStoreError::ReadManifestError)?;
|
||||
toml::from_str(&file_contents)
|
||||
.map_err(RecordingStoreError::ParseManifestError)
|
||||
}
|
||||
|
||||
// Closes the current entry (if needed), creates a new entry based on the
|
||||
// current time, and updates the manifest. Returns a tuple of the entry's
|
||||
// newly created QMDL file and analysis file.
|
||||
pub async fn new_entry(&mut self) -> Result<(File, File), RecordingStoreError> {
|
||||
// if we've already got an entry open, close it
|
||||
if self.current_entry.is_some() {
|
||||
self.close_current_entry().await?;
|
||||
}
|
||||
let new_entry = ManifestEntry::new();
|
||||
let qmdl_filepath = new_entry.get_qmdl_filepath(&self.path);
|
||||
let qmdl_file = File::options()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.open(&qmdl_filepath).await
|
||||
.map_err(RecordingStoreError::CreateFileError)?;
|
||||
let analysis_filepath = new_entry.get_analysis_filepath(&self.path);
|
||||
let analysis_file = File::options()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.open(&analysis_filepath).await
|
||||
.map_err(RecordingStoreError::CreateFileError)?;
|
||||
self.manifest.entries.push(new_entry);
|
||||
self.current_entry = Some(self.manifest.entries.len() - 1);
|
||||
self.write_manifest().await?;
|
||||
Ok((qmdl_file, analysis_file))
|
||||
}
|
||||
|
||||
// Returns the corresponding QMDL file for a given entry
|
||||
pub async fn open_entry_qmdl(&self, entry: &ManifestEntry) -> Result<File, RecordingStoreError> {
|
||||
File::open(entry.get_qmdl_filepath(&self.path)).await
|
||||
.map_err(RecordingStoreError::ReadFileError)
|
||||
}
|
||||
|
||||
// Returns the corresponding QMDL file for a given entry
|
||||
pub async fn open_entry_analysis(&self, entry: &ManifestEntry) -> Result<File, RecordingStoreError> {
|
||||
File::open(entry.get_analysis_filepath(&self.path)).await
|
||||
.map_err(RecordingStoreError::ReadFileError)
|
||||
}
|
||||
|
||||
// Unsets the current entry
|
||||
pub async fn close_current_entry(&mut self) -> Result<(), RecordingStoreError> {
|
||||
match self.current_entry {
|
||||
Some(_) => {
|
||||
self.current_entry = None;
|
||||
Ok(())
|
||||
},
|
||||
None => Err(RecordingStoreError::NoCurrentEntry)
|
||||
}
|
||||
}
|
||||
|
||||
// Sets the given entry's size and updates the last_message_time to now, updating the manifest
|
||||
pub async fn update_entry_qmdl_size(&mut self, entry_index: usize, size_bytes: usize) -> Result<(), RecordingStoreError> {
|
||||
self.manifest.entries[entry_index].qmdl_size_bytes = size_bytes;
|
||||
self.manifest.entries[entry_index].last_message_time = Some(Local::now());
|
||||
self.write_manifest().await
|
||||
}
|
||||
|
||||
// Sets the given entry's analysis file size
|
||||
pub async fn update_entry_analysis_size(&mut self, entry_index: usize, size_bytes: usize) -> Result<(), RecordingStoreError> {
|
||||
self.manifest.entries[entry_index].analysis_size_bytes = size_bytes;
|
||||
self.write_manifest().await
|
||||
}
|
||||
|
||||
async fn write_manifest(&mut self) -> Result<(), RecordingStoreError> {
|
||||
let mut manifest_file = File::options()
|
||||
.write(true)
|
||||
.open(self.path.join("manifest.toml")).await
|
||||
.map_err(RecordingStoreError::WriteManifestError)?;
|
||||
let manifest_contents = toml::to_string_pretty(&self.manifest)
|
||||
.expect("failed to serialize manifest");
|
||||
manifest_file.write_all(manifest_contents.as_bytes()).await
|
||||
.map_err(RecordingStoreError::WriteManifestError)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Finds an entry by filename
|
||||
pub fn entry_for_name(&self, name: &str) -> Option<ManifestEntry> {
|
||||
self.manifest.entries.iter()
|
||||
.find(|entry| entry.name == name)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub fn get_current_entry(&self) -> Option<&ManifestEntry> {
|
||||
let entry_index = self.current_entry?;
|
||||
self.manifest.entries.get(entry_index)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use tempdir::TempDir;
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_from_empty_dir() {
|
||||
let dir = TempDir::new("qmdl_store_test").unwrap();
|
||||
assert!(!RecordingStore::exists(dir.path()).await.unwrap());
|
||||
let _created_store = RecordingStore::create(dir.path()).await.unwrap();
|
||||
assert!(RecordingStore::exists(dir.path()).await.unwrap());
|
||||
let loaded_store = RecordingStore::load(dir.path()).await.unwrap();
|
||||
assert_eq!(loaded_store.manifest.entries.len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_creating_updating_and_closing_entries() {
|
||||
let dir = TempDir::new("qmdl_store_test").unwrap();
|
||||
let mut store = RecordingStore::create(dir.path()).await.unwrap();
|
||||
let _ = store.new_entry().await.unwrap();
|
||||
let entry_index = store.current_entry.unwrap();
|
||||
assert_eq!(RecordingStore::read_manifest(dir.path()).await.unwrap(), store.manifest);
|
||||
assert!(store.manifest.entries[entry_index].last_message_time.is_none());
|
||||
|
||||
store.update_entry_qmdl_size(entry_index, 1000).await.unwrap();
|
||||
let entry = store.entry_for_name(&store.manifest.entries[entry_index].name).unwrap();
|
||||
assert!(entry.last_message_time.is_some());
|
||||
assert_eq!(store.manifest.entries[entry_index].qmdl_size_bytes, 1000);
|
||||
assert_eq!(RecordingStore::read_manifest(dir.path()).await.unwrap(), store.manifest);
|
||||
|
||||
store.close_current_entry().await.unwrap();
|
||||
assert!(matches!(store.close_current_entry().await, Err(RecordingStoreError::NoCurrentEntry)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_repeated_new_entries() {
|
||||
let dir = TempDir::new("qmdl_store_test").unwrap();
|
||||
let mut store = RecordingStore::create(dir.path()).await.unwrap();
|
||||
let _ = store.new_entry().await.unwrap();
|
||||
let entry_index = store.current_entry.unwrap();
|
||||
let _ = store.new_entry().await.unwrap();
|
||||
let new_entry_index = store.current_entry.unwrap();
|
||||
assert_ne!(entry_index, new_entry_index);
|
||||
assert_eq!(store.manifest.entries.len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
use axum::body::Body;
|
||||
use axum::http::header::{CONTENT_TYPE, self};
|
||||
use axum::extract::State;
|
||||
use axum::http::{StatusCode, HeaderValue};
|
||||
use axum::response::{Response, IntoResponse};
|
||||
use axum::extract::Path;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::sync::mpsc::Sender;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio_util::io::ReaderStream;
|
||||
use include_dir::{include_dir, Dir};
|
||||
|
||||
use crate::DiagDeviceCtrlMessage;
|
||||
use crate::qmdl_store::RecordingStore;
|
||||
|
||||
pub struct ServerState {
|
||||
pub qmdl_store_lock: Arc<RwLock<RecordingStore>>,
|
||||
pub diag_device_ctrl_sender: Sender<DiagDeviceCtrlMessage>,
|
||||
pub readonly_mode: bool
|
||||
}
|
||||
|
||||
pub async fn get_qmdl(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 = qmdl_store.entry_for_name(&qmdl_name)
|
||||
.ok_or((StatusCode::NOT_FOUND, format!("couldn't find qmdl file with name {}", qmdl_name)))?;
|
||||
let qmdl_file = qmdl_store.open_entry_qmdl(&entry).await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("error opening QMDL file: {}", e)))?;
|
||||
let limited_qmdl_file = qmdl_file.take(entry.qmdl_size_bytes as u64);
|
||||
let qmdl_stream = ReaderStream::new(limited_qmdl_file);
|
||||
|
||||
let headers = [(CONTENT_TYPE, "application/octet-stream")];
|
||||
let body = Body::from_stream(qmdl_stream);
|
||||
Ok((headers, body).into_response())
|
||||
}
|
||||
|
||||
// Bundles the server's static files (html/css/js) into the binary for easy distribution
|
||||
static STATIC_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/static");
|
||||
|
||||
pub async fn serve_static(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,40 +0,0 @@
|
||||
td,
|
||||
th {
|
||||
border: 1px solid rgb(190, 190, 190);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
td {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
tr:nth-child(even) {
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
th[scope='col'] {
|
||||
background-color: #696969;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
th[scope='row'] {
|
||||
background-color: #d7d9f2;
|
||||
}
|
||||
|
||||
tr.current {
|
||||
background-color: #fe537b;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
caption {
|
||||
padding: 10px;
|
||||
caption-side: bottom;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
border: 2px solid rgb(200, 200, 200);
|
||||
letter-spacing: 1px;
|
||||
font-family: sans-serif;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>rayhunter</title>
|
||||
<link rel="stylesheet" type="text/css" href="css/style.css">
|
||||
<script src="js/main.js"></script>
|
||||
<script>
|
||||
async function repeatedlyPopulate() {
|
||||
await populateDivs();
|
||||
setTimeout(repeatedlyPopulate, 1000);
|
||||
}
|
||||
window.onload = function() {
|
||||
repeatedlyPopulate();
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div>
|
||||
<button onclick="startRecording()">Start Recording</button>
|
||||
<button onclick="stopRecording()">Stop Recording</button>
|
||||
</div>
|
||||
<table id="qmdl-manifest-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">Date Started</th>
|
||||
<th scope="col">Date of Last Message</th>
|
||||
<th scope="col">Size (bytes)</th>
|
||||
<th scope="col">PCAP</th>
|
||||
<th scope="col">QMDL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
<div>
|
||||
<h3>System stats</h3>
|
||||
<pre id="system-stats">Loading...</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Analysis Report</h3>
|
||||
<pre id="analysis-report">Loading...</pre>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,106 +0,0 @@
|
||||
async function populateDivs() {
|
||||
const systemStats = await getSystemStats();
|
||||
const systemStatsDiv = document.getElementById('system-stats');
|
||||
systemStatsDiv.innerHTML = JSON.stringify(systemStats, null, 2);
|
||||
|
||||
const analysisReport = await getAnalysisReport();
|
||||
const analysisReportDiv = document.getElementById('analysis-report');
|
||||
analysisReportDiv.innerHTML = JSON.stringify(analysisReport, null, 2);
|
||||
|
||||
const qmdlManifest = await getQmdlManifest();
|
||||
updateQmdlManifestTable(qmdlManifest);
|
||||
}
|
||||
|
||||
function updateQmdlManifestTable(manifest) {
|
||||
const table = document.getElementById('qmdl-manifest-table');
|
||||
const numRows = table.rows.length;
|
||||
for (let i=1; i<numRows; i++) {
|
||||
table.deleteRow(1);
|
||||
}
|
||||
if (manifest.current_entry) {
|
||||
const row = createEntryRow(manifest.current_entry);
|
||||
row.classList.add('current');
|
||||
table.appendChild(row)
|
||||
}
|
||||
for (let entry of manifest.entries) {
|
||||
table.appendChild(createEntryRow(entry));
|
||||
}
|
||||
}
|
||||
|
||||
function createEntryRow(entry) {
|
||||
const row = document.createElement('tr');
|
||||
const name = document.createElement('th');
|
||||
name.scope = 'row';
|
||||
name.innerText = entry.name;
|
||||
row.appendChild(name);
|
||||
for (const key of ['start_time', 'last_message_time', 'qmdl_size_bytes']) {
|
||||
const td = document.createElement('td');
|
||||
td.innerText = entry[key];
|
||||
row.appendChild(td);
|
||||
}
|
||||
const pcap_td = document.createElement('td');
|
||||
const pcap_link = document.createElement('a');
|
||||
pcap_link.href = `/api/pcap/${entry.name}`;
|
||||
pcap_link.innerText = 'pcap';
|
||||
pcap_td.appendChild(pcap_link);
|
||||
row.appendChild(pcap_td);
|
||||
const qmdl_td = document.createElement('td');
|
||||
const qmdl_link = document.createElement('a');
|
||||
qmdl_link.href = `/api/qmdl/${entry.name}`;
|
||||
qmdl_link.innerText = 'qmdl';
|
||||
qmdl_td.appendChild(qmdl_link);
|
||||
row.appendChild(qmdl_td);
|
||||
return row;
|
||||
}
|
||||
|
||||
async function getAnalysisReport() {
|
||||
const rows = await req('GET', '/api/analysis-report');
|
||||
return rows.split('\n')
|
||||
.filter(row => row.length > 0)
|
||||
.map(row => JSON.parse(row));
|
||||
}
|
||||
|
||||
async function getSystemStats() {
|
||||
return JSON.parse(await req('GET', '/api/system-stats'));
|
||||
}
|
||||
|
||||
async function getQmdlManifest() {
|
||||
const manifest = JSON.parse(await req('GET', '/api/qmdl-manifest'));
|
||||
if (manifest.current_entry) {
|
||||
manifest.current_entry.start_time = new Date(manifest.current_entry.start_time);
|
||||
if (manifest.current_entry.last_message_time === undefined) {
|
||||
manifest.current_entry.last_message_time = "N/A";
|
||||
} else {
|
||||
manifest.current_entry.last_message_time = new Date(manifest.current_entry.last_message_time);
|
||||
}
|
||||
}
|
||||
for (entry of manifest.entries) {
|
||||
entry.start_time = new Date(entry.start_time);
|
||||
entry.last_message_time = new Date(entry.last_message_time);
|
||||
}
|
||||
// sort them in reverse chronological order
|
||||
manifest.entries.reverse();
|
||||
return manifest;
|
||||
}
|
||||
|
||||
async function startRecording() {
|
||||
await req('POST', '/api/start-recording');
|
||||
populateDivs();
|
||||
}
|
||||
|
||||
async function stopRecording() {
|
||||
await req('POST', '/api/stop-recording');
|
||||
populateDivs();
|
||||
}
|
||||
|
||||
async function req(method, url) {
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
});
|
||||
const body = await response.text();
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
return body;
|
||||
} else {
|
||||
throw new Error(body);
|
||||
}
|
||||
}
|
||||
8
book.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[book]
|
||||
authors = ["The Rayhunter Team"]
|
||||
language = "en"
|
||||
src = "doc"
|
||||
title = "Rayhunter - An IMSI Catcher Catcher"
|
||||
|
||||
[output.html]
|
||||
edit-url-template = "https://github.com/efforg/rayhunter/edit/main/{path}"
|
||||
14
check/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "rayhunter-check"
|
||||
version = "0.5.0"
|
||||
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
@@ -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::QualitativeWarning { severity } => {
|
||||
warn!(
|
||||
"{}: WARNING (Severity: {:?}) - {} {}",
|
||||
self.file_path, severity, 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
26
daemon/Cargo.toml
Normal file
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "rayhunter-daemon"
|
||||
version = "0.5.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
rayhunter = { path = "../lib" }
|
||||
toml = "0.8.8"
|
||||
serde = { version = "1.0.193", features = ["derive"] }
|
||||
tokio = { version = "1.44.2", default-features = false, features = ["fs", "signal", "process", "rt-multi-thread"] }
|
||||
axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json"] }
|
||||
thiserror = "1.0.52"
|
||||
libc = "0.2.150"
|
||||
log = "0.4.20"
|
||||
env_logger = { version = "0.11", default-features = false }
|
||||
tokio-util = { version = "0.7.10", features = ["rt", "io", "compat"] }
|
||||
futures-macro = "0.3.30"
|
||||
include_dir = "0.7.3"
|
||||
chrono = { version = "0.4.31", features = ["serde"] }
|
||||
tokio-stream = { version = "0.1.14", default-features = false }
|
||||
futures = { version = "0.3.30", default-features = false }
|
||||
serde_json = "1.0.114"
|
||||
image = { version = "0.25.1", default-features = false, features = ["png", "gif"] }
|
||||
tempfile = "3.10.1"
|
||||
async_zip = { version = "0.0.17", features = ["tokio"] }
|
||||
anyhow = "1.0.98"
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
265
daemon/src/analysis.rs
Normal file
@@ -0,0 +1,265 @@
|
||||
use std::sync::Arc;
|
||||
use std::{future, pin};
|
||||
|
||||
use axum::Json;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use futures::TryStreamExt;
|
||||
use log::{error, info};
|
||||
use rayhunter::analysis::analyzer::{AnalyzerConfig, Harness};
|
||||
use rayhunter::diag::{DataType, MessagesContainer};
|
||||
use rayhunter::qmdl::QmdlReader;
|
||||
use serde::Serialize;
|
||||
use tokio::fs::File;
|
||||
use tokio::io::{AsyncWriteExt, BufWriter};
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use tokio::sync::{RwLock, RwLockWriteGuard};
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
use crate::qmdl_store::RecordingStore;
|
||||
use crate::server::ServerState;
|
||||
|
||||
pub struct AnalysisWriter {
|
||||
writer: BufWriter<File>,
|
||||
harness: Harness,
|
||||
}
|
||||
|
||||
// We write our analysis results to a file immediately to minimize the amount of
|
||||
// state Rayhunter has to keep track of in memory. The analysis file's format is
|
||||
// Newline Delimited JSON
|
||||
// (https://docs.mulesoft.com/dataweave/latest/dataweave-formats-ndjson), which
|
||||
// lets us simply append new rows to the end without parsing the entire JSON
|
||||
// object beforehand.
|
||||
impl AnalysisWriter {
|
||||
pub async fn new(file: File, analyzer_config: &AnalyzerConfig) -> Result<Self, std::io::Error> {
|
||||
let harness = Harness::new_with_config(analyzer_config);
|
||||
|
||||
let mut result = Self {
|
||||
writer: BufWriter::new(file),
|
||||
harness,
|
||||
};
|
||||
let metadata = result.harness.get_metadata();
|
||||
result.write(&metadata).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// Runs the analysis harness on the given container, serializing the results
|
||||
// to the analysis file, returning the whether any warnings were detected
|
||||
pub async fn analyze(&mut self, container: MessagesContainer) -> Result<bool, std::io::Error> {
|
||||
let mut warning_detected = false;
|
||||
for row in self.harness.analyze_qmdl_messages(container) {
|
||||
if !row.is_empty() {
|
||||
self.write(&row).await?;
|
||||
}
|
||||
warning_detected |= row.contains_warnings();
|
||||
}
|
||||
Ok(warning_detected)
|
||||
}
|
||||
|
||||
async fn write<T: Serialize>(&mut self, value: &T) -> Result<(), std::io::Error> {
|
||||
let mut value_str = serde_json::to_string(value).unwrap();
|
||||
value_str.push('\n');
|
||||
self.writer.write_all(value_str.as_bytes()).await?;
|
||||
self.writer.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Flushes any pending I/O to disk before dropping the writer
|
||||
pub async fn close(mut self) -> Result<(), std::io::Error> {
|
||||
self.writer.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct AnalysisStatus {
|
||||
queued: Vec<String>,
|
||||
running: Option<String>,
|
||||
finished: Vec<String>,
|
||||
}
|
||||
|
||||
impl AnalysisStatus {
|
||||
pub fn new(store: &RecordingStore) -> Self {
|
||||
let existing_recordings: Vec<String> = store
|
||||
.manifest
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| entry.name.clone())
|
||||
.collect();
|
||||
AnalysisStatus {
|
||||
queued: Vec::new(),
|
||||
running: None,
|
||||
finished: existing_recordings,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum AnalysisCtrlMessage {
|
||||
NewFilesQueued,
|
||||
RecordingFinished(String),
|
||||
Exit,
|
||||
}
|
||||
|
||||
async fn queued_len(analysis_status_lock: Arc<RwLock<AnalysisStatus>>) -> usize {
|
||||
analysis_status_lock.read().await.queued.len()
|
||||
}
|
||||
|
||||
async fn dequeue_to_running(analysis_status_lock: Arc<RwLock<AnalysisStatus>>) -> String {
|
||||
let mut analysis_status = analysis_status_lock.write().await;
|
||||
let name = analysis_status.queued.remove(0);
|
||||
assert!(analysis_status.running.is_none());
|
||||
analysis_status.running = Some(name.clone());
|
||||
name
|
||||
}
|
||||
|
||||
async fn finish_running_analysis(analysis_status_lock: Arc<RwLock<AnalysisStatus>>) {
|
||||
let mut analysis_status = analysis_status_lock.write().await;
|
||||
let finished = analysis_status.running.take().unwrap();
|
||||
analysis_status.finished.push(finished);
|
||||
}
|
||||
|
||||
async fn perform_analysis(
|
||||
name: &str,
|
||||
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
|
||||
analyzer_config: &AnalyzerConfig,
|
||||
) -> Result<(), String> {
|
||||
info!("Opening QMDL and analysis file for {name}...");
|
||||
let (analysis_file, qmdl_file) = {
|
||||
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||
let (entry_index, _) = qmdl_store
|
||||
.entry_for_name(name)
|
||||
.ok_or(format!("failed to find QMDL store entry for {name}"))?;
|
||||
let analysis_file = qmdl_store
|
||||
.clear_and_open_entry_analysis(entry_index)
|
||||
.await
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
let qmdl_file = qmdl_store
|
||||
.open_entry_qmdl(entry_index)
|
||||
.await
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
|
||||
(analysis_file, qmdl_file)
|
||||
};
|
||||
|
||||
let mut analysis_writer = AnalysisWriter::new(analysis_file, analyzer_config)
|
||||
.await
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
let file_size = qmdl_file
|
||||
.metadata()
|
||||
.await
|
||||
.expect("failed to get QMDL file metadata")
|
||||
.len();
|
||||
let mut qmdl_reader = QmdlReader::new(qmdl_file, Some(file_size as usize));
|
||||
let mut qmdl_stream = pin::pin!(
|
||||
qmdl_reader
|
||||
.as_stream()
|
||||
.try_filter(|container| future::ready(container.data_type == DataType::UserSpace))
|
||||
);
|
||||
|
||||
info!("Starting analysis for {name}...");
|
||||
while let Some(container) = qmdl_stream
|
||||
.try_next()
|
||||
.await
|
||||
.expect("failed getting QMDL container")
|
||||
{
|
||||
let _ = analysis_writer
|
||||
.analyze(container)
|
||||
.await
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
}
|
||||
|
||||
analysis_writer
|
||||
.close()
|
||||
.await
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
info!("Analysis for {name} complete!");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run_analysis_thread(
|
||||
task_tracker: &TaskTracker,
|
||||
mut analysis_rx: Receiver<AnalysisCtrlMessage>,
|
||||
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
|
||||
analysis_status_lock: Arc<RwLock<AnalysisStatus>>,
|
||||
analyzer_config: AnalyzerConfig,
|
||||
) {
|
||||
task_tracker.spawn(async move {
|
||||
loop {
|
||||
match analysis_rx.recv().await {
|
||||
Some(AnalysisCtrlMessage::NewFilesQueued) => {
|
||||
let count = queued_len(analysis_status_lock.clone()).await;
|
||||
for _ in 0..count {
|
||||
let name = dequeue_to_running(analysis_status_lock.clone()).await;
|
||||
if let Err(err) =
|
||||
perform_analysis(&name, qmdl_store_lock.clone(), &analyzer_config).await
|
||||
{
|
||||
error!("failed to analyze {name}: {err}");
|
||||
}
|
||||
finish_running_analysis(analysis_status_lock.clone()).await;
|
||||
}
|
||||
}
|
||||
Some(AnalysisCtrlMessage::RecordingFinished(name)) => {
|
||||
let mut status = analysis_status_lock.write().await;
|
||||
status.finished.push(name);
|
||||
}
|
||||
Some(AnalysisCtrlMessage::Exit) | None => return,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn get_analysis_status(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
) -> Result<Json<AnalysisStatus>, (StatusCode, String)> {
|
||||
Ok(Json(state.analysis_status_lock.read().await.clone()))
|
||||
}
|
||||
|
||||
fn queue_qmdl(name: &str, analysis_status: &mut RwLockWriteGuard<AnalysisStatus>) -> bool {
|
||||
if analysis_status.queued.iter().any(|n| n == name)
|
||||
|| analysis_status.running.iter().any(|n| n == name)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
analysis_status.queued.push(name.to_string());
|
||||
true
|
||||
}
|
||||
|
||||
pub async fn start_analysis(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
Path(qmdl_name): Path<String>,
|
||||
) -> Result<(StatusCode, Json<AnalysisStatus>), (StatusCode, String)> {
|
||||
let mut analysis_status = state.analysis_status_lock.write().await;
|
||||
let store = state.qmdl_store_lock.read().await;
|
||||
let queued = if qmdl_name.is_empty() {
|
||||
let mut entry_names: Vec<&str> = store
|
||||
.manifest
|
||||
.entries
|
||||
.iter()
|
||||
.map(|e| e.name.as_str())
|
||||
.collect();
|
||||
if let Some(current_entry) = store.current_entry {
|
||||
entry_names.remove(current_entry);
|
||||
}
|
||||
entry_names
|
||||
.iter()
|
||||
.any(|name| queue_qmdl(name, &mut analysis_status))
|
||||
} else {
|
||||
queue_qmdl(&qmdl_name, &mut analysis_status)
|
||||
};
|
||||
if queued {
|
||||
state
|
||||
.analysis_sender
|
||||
.send(AnalysisCtrlMessage::NewFilesQueued)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("failed to queue new analysis files: {e:?}"),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Ok((StatusCode::ACCEPTED, Json(analysis_status.clone())))
|
||||
}
|
||||
62
daemon/src/config.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use log::warn;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use rayhunter::Device;
|
||||
use rayhunter::analysis::analyzer::AnalyzerConfig;
|
||||
|
||||
use crate::error::RayhunterError;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
pub qmdl_store_path: String,
|
||||
pub port: u16,
|
||||
pub debug_mode: bool,
|
||||
pub device: Device,
|
||||
pub ui_level: u8,
|
||||
pub colorblind_mode: bool,
|
||||
pub key_input_mode: u8,
|
||||
pub analyzers: AnalyzerConfig,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Config {
|
||||
qmdl_store_path: "/data/rayhunter/qmdl".to_string(),
|
||||
port: 8080,
|
||||
debug_mode: false,
|
||||
device: Device::Orbic,
|
||||
ui_level: 1,
|
||||
colorblind_mode: false,
|
||||
key_input_mode: 0,
|
||||
analyzers: AnalyzerConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn parse_config<P>(path: P) -> Result<Config, RayhunterError>
|
||||
where
|
||||
P: AsRef<std::path::Path>,
|
||||
{
|
||||
if let Ok(config_file) = tokio::fs::read_to_string(&path).await {
|
||||
Ok(toml::from_str(&config_file).map_err(RayhunterError::ConfigFileParsingError)?)
|
||||
} else {
|
||||
warn!("unable to read config file, using default config");
|
||||
Ok(Config::default())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Args {
|
||||
pub config_path: String,
|
||||
}
|
||||
|
||||
pub fn parse_args() -> Args {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if args.len() != 2 {
|
||||
println!("Usage: {} /path/to/config/file", args[0]);
|
||||
std::process::exit(1);
|
||||
}
|
||||
Args {
|
||||
config_path: args[1].clone(),
|
||||
}
|
||||
}
|
||||
299
daemon/src/diag.rs
Normal file
@@ -0,0 +1,299 @@
|
||||
use std::pin::pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
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};
|
||||
use log::{debug, error, info, warn};
|
||||
use rayhunter::analysis::analyzer::AnalyzerConfig;
|
||||
use rayhunter::diag::DataType;
|
||||
use rayhunter::diag_device::DiagDevice;
|
||||
use rayhunter::qmdl::QmdlWriter;
|
||||
use tokio::fs::File;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::sync::mpsc::{Receiver, Sender};
|
||||
use tokio_util::io::ReaderStream;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
use crate::analysis::{AnalysisCtrlMessage, AnalysisWriter};
|
||||
use crate::display;
|
||||
use crate::qmdl_store::{EntryType, RecordingStore, RecordingStoreError};
|
||||
use crate::server::ServerState;
|
||||
|
||||
pub enum DiagDeviceCtrlMessage {
|
||||
StopRecording,
|
||||
StartRecording,
|
||||
Exit,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
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>>,
|
||||
analysis_sender: Sender<AnalysisCtrlMessage>,
|
||||
analyzer_config: AnalyzerConfig,
|
||||
) {
|
||||
task_tracker.spawn(async move {
|
||||
let (initial_qmdl_file, initial_analysis_file) = qmdl_store_lock.write().await.new_entry().await.expect("failed creating QMDL file entry");
|
||||
let mut maybe_qmdl_writer: Option<QmdlWriter<File>> = Some(QmdlWriter::new(initial_qmdl_file));
|
||||
let mut diag_stream = pin!(dev.as_stream().into_stream());
|
||||
let mut maybe_analysis_writer = Some(AnalysisWriter::new(initial_analysis_file, &analyzer_config).await
|
||||
.expect("failed to create analysis writer"));
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg = qmdl_file_rx.recv() => {
|
||||
match msg {
|
||||
Some(DiagDeviceCtrlMessage::StartRecording) => {
|
||||
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||
let (qmdl_file, new_analysis_file) = match qmdl_store.new_entry().await {
|
||||
Ok(x) => x,
|
||||
Err(e) => {
|
||||
error!("couldn't create new qmdl entry: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
maybe_qmdl_writer = Some(QmdlWriter::new(qmdl_file));
|
||||
|
||||
if let Some(analysis_writer) = maybe_analysis_writer {
|
||||
analysis_writer.close().await.expect("failed to close analysis writer");
|
||||
}
|
||||
|
||||
maybe_analysis_writer = Some(AnalysisWriter::new(new_analysis_file, &analyzer_config).await
|
||||
.expect("failed to write to analysis file"));
|
||||
|
||||
if let Err(e) = ui_update_sender.send(display::DisplayState::Recording).await {
|
||||
warn!("couldn't send ui update message: {e}");
|
||||
}
|
||||
},
|
||||
Some(DiagDeviceCtrlMessage::StopRecording) => {
|
||||
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||
if let Some((_, entry)) = qmdl_store.get_current_entry() {
|
||||
if let Err(e) = analysis_sender
|
||||
.send(AnalysisCtrlMessage::RecordingFinished(
|
||||
entry.name.to_string(),
|
||||
))
|
||||
.await {
|
||||
warn!("couldn't send analysis message: {e}");
|
||||
}
|
||||
}
|
||||
if let Err(e) = qmdl_store.close_current_entry().await {
|
||||
error!("couldn't close current entry: {e}");
|
||||
}
|
||||
|
||||
maybe_qmdl_writer = None;
|
||||
if let Some(analysis_writer) = maybe_analysis_writer {
|
||||
analysis_writer.close().await.expect("failed to close analysis writer");
|
||||
}
|
||||
maybe_analysis_writer = None;
|
||||
|
||||
if let Err(e) = ui_update_sender.send(display::DisplayState::Paused).await {
|
||||
warn!("couldn't send ui update message: {e}");
|
||||
}
|
||||
},
|
||||
// None means all the Senders have been dropped, so it's
|
||||
// time to go
|
||||
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 heuristic_warning = analysis_writer.analyze(container).await
|
||||
.expect("failed to analyze container");
|
||||
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: {}");
|
||||
}
|
||||
}
|
||||
},
|
||||
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.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()))
|
||||
}
|
||||
|
||||
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 mut qmdl_store = state.qmdl_store_lock.write().await;
|
||||
match qmdl_store.delete_entry(&qmdl_name).await {
|
||||
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}"),
|
||||
)),
|
||||
Ok(entry_type) => {
|
||||
if entry_type == EntryType::Current {
|
||||
state
|
||||
.diag_device_ctrl_sender
|
||||
.send(DiagDeviceCtrlMessage::StopRecording)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("couldn't send stop recording message: {e}"),
|
||||
)
|
||||
})?;
|
||||
state
|
||||
.ui_update_sender
|
||||
.send(display::DisplayState::Paused)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("couldn't send ui update message: {e}"),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Ok((StatusCode::ACCEPTED, "ok".to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_all_recordings(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||
if state.config.debug_mode {
|
||||
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
|
||||
}
|
||||
state
|
||||
.diag_device_ctrl_sender
|
||||
.send(DiagDeviceCtrlMessage::StopRecording)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("couldn't send stop recording message: {e}"),
|
||||
)
|
||||
})?;
|
||||
let mut qmdl_store = state.qmdl_store_lock.write().await;
|
||||
qmdl_store.delete_all_entries().await.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("couldn't delete all recordings: {e}"),
|
||||
)
|
||||
})?;
|
||||
state
|
||||
.ui_update_sender
|
||||
.send(display::DisplayState::Paused)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("couldn't send ui update message: {e}"),
|
||||
)
|
||||
})?;
|
||||
Ok((StatusCode::ACCEPTED, "ok".to_string()))
|
||||
}
|
||||
|
||||
pub async fn get_analysis_report(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
Path(qmdl_name): Path<String>,
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
let qmdl_store = state.qmdl_store_lock.read().await;
|
||||
let (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())
|
||||
}
|
||||
199
daemon/src/display/generic_framebuffer.rs
Normal file
@@ -0,0 +1,199 @@
|
||||
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 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::{Dir, include_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));
|
||||
}
|
||||
});
|
||||
}
|
||||
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.");
|
||||
}
|
||||
16
daemon/src/display/mod.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
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 wingtech;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum DisplayState {
|
||||
Recording,
|
||||
Paused,
|
||||
WarningDetected,
|
||||
}
|
||||
49
daemon/src/display/orbic.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
use crate::config;
|
||||
use crate::display::DisplayState;
|
||||
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
|
||||
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
const FB_PATH: &str = "/dev/fb0";
|
||||
|
||||
#[derive(Copy, Clone, Default)]
|
||||
struct Framebuffer;
|
||||
|
||||
impl GenericFramebuffer for Framebuffer {
|
||||
fn dimensions(&self) -> Dimensions {
|
||||
// TODO actually poll for this, maybe w/ fbset?
|
||||
Dimensions {
|
||||
height: 128,
|
||||
width: 128,
|
||||
}
|
||||
}
|
||||
|
||||
fn write_buffer(&mut self, buffer: &[(u8, u8, u8)]) {
|
||||
let mut raw_buffer = Vec::new();
|
||||
for (r, g, b) in buffer {
|
||||
let mut rgb565: u16 = (*r as u16 & 0b11111000) << 8;
|
||||
rgb565 |= (*g as u16 & 0b11111100) << 3;
|
||||
rgb565 |= (*b as u16) >> 3;
|
||||
raw_buffer.extend(rgb565.to_le_bytes());
|
||||
}
|
||||
|
||||
std::fs::write(FB_PATH, &raw_buffer).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_ui(
|
||||
task_tracker: &TaskTracker,
|
||||
config: &config::Config,
|
||||
ui_shutdown_rx: oneshot::Receiver<()>,
|
||||
ui_update_rx: Receiver<DisplayState>,
|
||||
) {
|
||||
generic_framebuffer::update_ui(
|
||||
task_tracker,
|
||||
config,
|
||||
Framebuffer,
|
||||
ui_shutdown_rx,
|
||||
ui_update_rx,
|
||||
)
|
||||
}
|
||||
83
daemon/src/display/tmobile.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
/// 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::fs::write;
|
||||
use std::thread::sleep;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config;
|
||||
use crate::display::DisplayState;
|
||||
|
||||
macro_rules! led {
|
||||
($l:expr) => {{ format!("/sys/class/leds/led:{}/blink", $l) }};
|
||||
}
|
||||
|
||||
fn start_blinking(path: String) {
|
||||
write(&path, "1").ok();
|
||||
}
|
||||
|
||||
fn stop_blinking(path: String) {
|
||||
write(&path, "0").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_blocking(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 {
|
||||
sleep(Duration::from_secs(1));
|
||||
continue;
|
||||
}
|
||||
match state {
|
||||
DisplayState::Paused => {
|
||||
stop_blinking(led!("signal_blue"));
|
||||
stop_blinking(led!("signal_red"));
|
||||
start_blinking(led!("wlan_white"));
|
||||
}
|
||||
DisplayState::Recording => {
|
||||
stop_blinking(led!("wlan_white"));
|
||||
stop_blinking(led!("signal_red"));
|
||||
start_blinking(led!("signal_blue"));
|
||||
}
|
||||
DisplayState::WarningDetected => {
|
||||
stop_blinking(led!("wlan_white"));
|
||||
stop_blinking(led!("signal_blue"));
|
||||
start_blinking(led!("signal_red"));
|
||||
}
|
||||
}
|
||||
last_state = state;
|
||||
sleep(Duration::from_secs(1));
|
||||
}
|
||||
});
|
||||
}
|
||||
29
daemon/src/display/tplink.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use log::info;
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
use crate::config;
|
||||
use crate::display::{DisplayState, tplink_framebuffer, tplink_onebit};
|
||||
|
||||
use std::fs;
|
||||
|
||||
pub fn update_ui(
|
||||
task_tracker: &TaskTracker,
|
||||
config: &config::Config,
|
||||
ui_shutdown_rx: oneshot::Receiver<()>,
|
||||
ui_update_rx: Receiver<DisplayState>,
|
||||
) {
|
||||
let display_level = config.ui_level;
|
||||
if display_level == 0 {
|
||||
info!("Invisible mode, not spawning UI.");
|
||||
}
|
||||
|
||||
if fs::exists(tplink_onebit::OLED_PATH).unwrap_or_default() {
|
||||
info!("detected one-bit display");
|
||||
tplink_onebit::update_ui(task_tracker, config, ui_shutdown_rx, ui_update_rx)
|
||||
} else {
|
||||
info!("fallback to framebuffer");
|
||||
tplink_framebuffer::update_ui(task_tracker, config, ui_shutdown_rx, ui_update_rx)
|
||||
}
|
||||
}
|
||||
90
daemon/src/display/tplink_framebuffer.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::os::fd::AsRawFd;
|
||||
|
||||
use crate::config;
|
||||
use crate::display::DisplayState;
|
||||
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
|
||||
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
const FB_PATH: &str = "/dev/fb0";
|
||||
|
||||
struct Framebuffer;
|
||||
|
||||
#[repr(C)]
|
||||
struct fb_fillrect {
|
||||
dx: u32,
|
||||
dy: u32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
color: u32,
|
||||
rop: u32,
|
||||
}
|
||||
|
||||
impl GenericFramebuffer for Framebuffer {
|
||||
fn dimensions(&self) -> Dimensions {
|
||||
// TODO actually poll for this, maybe w/ fbset?
|
||||
Dimensions {
|
||||
height: 128,
|
||||
width: 128,
|
||||
}
|
||||
}
|
||||
|
||||
fn write_buffer(&mut self, buffer: &[(u8, u8, u8)]) {
|
||||
// for how to write to the buffer, consult M7350v5_en_gpl/bootable/recovery/recovery_color_oled.c
|
||||
let dimensions = self.dimensions();
|
||||
let width = dimensions.width;
|
||||
let height = buffer.len() as u32 / width;
|
||||
let mut f = File::options().write(true).open(FB_PATH).unwrap();
|
||||
let mut arg = fb_fillrect {
|
||||
dx: 0,
|
||||
dy: 0,
|
||||
width,
|
||||
height,
|
||||
color: 0xffff, // not sure what this is
|
||||
rop: 0,
|
||||
};
|
||||
|
||||
let mut raw_buffer = Vec::new();
|
||||
for (r, g, b) in buffer {
|
||||
let mut rgb565: u16 = (*r as u16 & 0b11111000) << 8;
|
||||
rgb565 |= (*g as u16 & 0b11111100) << 3;
|
||||
rgb565 |= (*b as u16) >> 3;
|
||||
// note: big-endian!
|
||||
raw_buffer.extend(rgb565.to_be_bytes());
|
||||
}
|
||||
|
||||
f.write_all(&raw_buffer).unwrap();
|
||||
|
||||
unsafe {
|
||||
let res = libc::ioctl(
|
||||
f.as_raw_fd(),
|
||||
0x4619, // FBIORECT_DISPLAY
|
||||
&mut arg as *mut _,
|
||||
std::mem::size_of::<fb_fillrect>(),
|
||||
);
|
||||
|
||||
if res < 0 {
|
||||
panic!("failed to send FBIORECT_DISPLAY ioctl, {res}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_ui(
|
||||
task_tracker: &TaskTracker,
|
||||
config: &config::Config,
|
||||
ui_shutdown_rx: oneshot::Receiver<()>,
|
||||
ui_update_rx: Receiver<DisplayState>,
|
||||
) {
|
||||
generic_framebuffer::update_ui(
|
||||
task_tracker,
|
||||
config,
|
||||
Framebuffer,
|
||||
ui_shutdown_rx,
|
||||
ui_update_rx,
|
||||
)
|
||||
}
|
||||
170
daemon/src/display/tplink_onebit.rs
Normal file
@@ -0,0 +1,170 @@
|
||||
/// Display module for the TP-Link M7350 oled one-bit display.
|
||||
///
|
||||
/// https://github.com/m0veax/tplink_m7350/tree/main/oled
|
||||
use crate::config;
|
||||
use crate::display::DisplayState;
|
||||
|
||||
use log::{error, info};
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::sync::oneshot::error::TryRecvError;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
use std::fs;
|
||||
use std::thread::sleep;
|
||||
use std::time::Duration;
|
||||
|
||||
pub const OLED_PATH: &str = "/sys/class/display/oled/oled_buffer";
|
||||
|
||||
// those coordinates were mainly chosen for a spot that doesn't get regularly updated by the main
|
||||
// oledd service. otherwise we'd have to write to the display more than once per second to prevent
|
||||
// the icon from flickering.
|
||||
const STATUS_X: u8 = 104;
|
||||
const STATUS_Y: u8 = 40;
|
||||
const STATUS_W: u8 = 16;
|
||||
const STATUS_H: u8 = 16;
|
||||
|
||||
macro_rules! pixel {
|
||||
(x) => {
|
||||
0
|
||||
};
|
||||
(_) => {
|
||||
1
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! pixelart {
|
||||
(x=$x:expr, y=$y:expr, width=$width:expr, height=$height:expr; $($a:tt $b:tt $c:tt $d:tt $e:tt $f:tt $g:tt $h:tt)*) => {{
|
||||
// one bit per pixel + 4 bytes for header
|
||||
const BUF_SIZE: usize = ($width as usize * $height as usize) / 8 + 4;
|
||||
const BUF_BYTES: [u8; BUF_SIZE] = [
|
||||
$x,
|
||||
$y,
|
||||
$width,
|
||||
$height,
|
||||
$(
|
||||
(pixel!($a) << 7 | pixel!($b) << 6 | pixel!($c) << 5 | pixel!($d) << 4 | pixel!($e) << 3 | pixel!($f) << 2 | pixel!($g) << 1 | pixel!($h)),
|
||||
)*
|
||||
];
|
||||
|
||||
&BUF_BYTES
|
||||
}}
|
||||
}
|
||||
|
||||
const STATUS_PAUSED: &[u8] = pixelart! {
|
||||
x=STATUS_X, y=STATUS_Y, width=STATUS_W, height=STATUS_H;
|
||||
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
|
||||
_ _ _ x x x x x x x x x x _ _ _
|
||||
_ x x _ _ _ _ _ _ _ _ _ _ x x _
|
||||
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
|
||||
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
|
||||
_ x _ _ _ x _ _ _ _ x _ _ _ x _
|
||||
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
|
||||
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
|
||||
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
|
||||
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
|
||||
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
|
||||
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
|
||||
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
|
||||
_ x x _ _ _ _ _ _ _ _ _ _ x x _
|
||||
_ _ _ x x x x x x x x x x _ _ _
|
||||
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
|
||||
};
|
||||
|
||||
const STATUS_SMILING: &[u8] = pixelart! {
|
||||
x=STATUS_X, y=STATUS_Y, width=STATUS_W, height=STATUS_H;
|
||||
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
|
||||
_ _ _ x x x x x x x x x x _ _ _
|
||||
_ x x _ _ _ _ _ _ _ _ _ _ x x _
|
||||
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
|
||||
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
|
||||
_ x _ _ _ x _ _ _ _ x _ _ _ x _
|
||||
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
|
||||
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
|
||||
_ x _ _ _ x _ _ _ _ x _ _ _ x _
|
||||
_ x _ _ _ x _ _ _ _ x _ _ _ x _
|
||||
_ x _ _ _ x x x x x x _ _ _ x _
|
||||
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
|
||||
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
|
||||
_ x x _ _ _ _ _ _ _ _ _ _ x x _
|
||||
_ _ _ x x x x x x x x x x _ _ _
|
||||
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
|
||||
};
|
||||
|
||||
const STATUS_WARNING: &[u8] = pixelart! {
|
||||
x=STATUS_X, y=STATUS_Y, width=STATUS_W, height=STATUS_H;
|
||||
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
|
||||
_ _ _ x x x x x x x x x x _ _ _
|
||||
_ x x _ _ _ _ _ _ _ _ _ _ x x _
|
||||
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
|
||||
_ x _ _ _ _ _ x x _ _ _ _ _ x _
|
||||
_ x _ _ _ _ _ x x _ _ _ _ _ x _
|
||||
_ x _ _ _ _ _ x x _ _ _ _ _ x _
|
||||
_ x _ _ _ _ _ x x _ _ _ _ _ x _
|
||||
_ x _ _ _ _ _ x x _ _ _ _ _ x _
|
||||
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
|
||||
_ x _ _ _ _ _ x x _ _ _ _ _ x _
|
||||
_ x _ _ _ _ _ x x _ _ _ _ _ x _
|
||||
_ x _ _ _ _ _ _ _ _ _ _ _ _ x _
|
||||
_ x x _ _ _ _ _ _ _ _ _ _ x x _
|
||||
_ _ _ x x x x x x x x x x _ _ _
|
||||
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
|
||||
};
|
||||
|
||||
pub fn update_ui(
|
||||
task_tracker: &TaskTracker,
|
||||
config: &config::Config,
|
||||
mut ui_shutdown_rx: oneshot::Receiver<()>,
|
||||
mut ui_update_rx: Receiver<DisplayState>,
|
||||
) {
|
||||
let display_level = config.ui_level;
|
||||
if display_level == 0 {
|
||||
info!("Invisible mode, not spawning UI.");
|
||||
}
|
||||
|
||||
task_tracker.spawn_blocking(move || {
|
||||
let mut pixels = STATUS_SMILING;
|
||||
|
||||
loop {
|
||||
match ui_shutdown_rx.try_recv() {
|
||||
Ok(_) => {
|
||||
info!("received UI shutdown");
|
||||
break;
|
||||
}
|
||||
Err(TryRecvError::Empty) => {}
|
||||
Err(e) => panic!("error receiving shutdown message: {e}"),
|
||||
}
|
||||
|
||||
match ui_update_rx.try_recv() {
|
||||
Ok(DisplayState::Paused) => pixels = STATUS_PAUSED,
|
||||
Ok(DisplayState::Recording) => pixels = STATUS_SMILING,
|
||||
Ok(DisplayState::WarningDetected) => pixels = STATUS_WARNING,
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
|
||||
Err(e) => {
|
||||
error!("error receiving framebuffer update message: {e}");
|
||||
}
|
||||
};
|
||||
|
||||
// we write the status every second because it may have been overwritten through menu
|
||||
// navigation.
|
||||
if display_level != 0 {
|
||||
if let Err(e) = fs::write(OLED_PATH, pixels) {
|
||||
error!("failed to write to display: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
sleep(Duration::from_millis(1000));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pixelart_macro() {
|
||||
assert_eq!(
|
||||
STATUS_WARNING,
|
||||
[
|
||||
104, 40, 16, 16, 255, 255, 224, 7, 159, 249, 191, 253, 190, 125, 190, 125, 190, 125,
|
||||
190, 125, 190, 125, 191, 253, 190, 125, 190, 125, 191, 253, 159, 249, 224, 7, 255, 255
|
||||
]
|
||||
);
|
||||
}
|
||||
54
daemon/src/display/wingtech.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
/// Display support for the Wingtech CT2MHS01 hotspot.
|
||||
///
|
||||
/// Tested on (from `/etc/wt_version`):
|
||||
/// WT_INNER_VERSION=SW_Q89323AA1_V057_M10_CRICKET_USR_MP
|
||||
/// WT_PRODUCTION_VERSION=CT2MHS01_0.04.55
|
||||
/// WT_HARDWARE_VERSION=89323_1_20
|
||||
use crate::config;
|
||||
use crate::display::DisplayState;
|
||||
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
|
||||
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
const FB_PATH: &str = "/dev/fb0";
|
||||
|
||||
#[derive(Copy, Clone, Default)]
|
||||
struct Framebuffer;
|
||||
|
||||
impl GenericFramebuffer for Framebuffer {
|
||||
fn dimensions(&self) -> Dimensions {
|
||||
Dimensions {
|
||||
height: 128,
|
||||
width: 160,
|
||||
}
|
||||
}
|
||||
|
||||
fn write_buffer(&mut self, buffer: &[(u8, u8, u8)]) {
|
||||
let mut raw_buffer = Vec::new();
|
||||
for (r, g, b) in buffer {
|
||||
let mut rgb565: u16 = (*r as u16 & 0b11111000) << 8;
|
||||
rgb565 |= (*g as u16 & 0b11111100) << 3;
|
||||
rgb565 |= (*b as u16) >> 3;
|
||||
raw_buffer.extend(rgb565.to_le_bytes());
|
||||
}
|
||||
|
||||
std::fs::write(FB_PATH, &raw_buffer).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_ui(
|
||||
task_tracker: &TaskTracker,
|
||||
config: &config::Config,
|
||||
ui_shutdown_rx: oneshot::Receiver<()>,
|
||||
ui_update_rx: Receiver<DisplayState>,
|
||||
) {
|
||||
generic_framebuffer::update_ui(
|
||||
task_tracker,
|
||||
config,
|
||||
Framebuffer,
|
||||
ui_shutdown_rx,
|
||||
ui_update_rx,
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
use thiserror::Error;
|
||||
use rayhunter::diag_device::DiagDeviceError;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::qmdl_store::RecordingStoreError;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum RayhunterError{
|
||||
pub enum RayhunterError {
|
||||
#[error("Config file parsing error: {0}")]
|
||||
ConfigFileParsingError(#[from] toml::de::Error),
|
||||
#[error("Diag intialization error: {0}")]
|
||||
@@ -13,6 +13,6 @@ pub enum RayhunterError{
|
||||
TokioError(#[from] tokio::io::Error),
|
||||
#[error("QmdlStore error: {0}")]
|
||||
QmdlStoreError(#[from] RecordingStoreError),
|
||||
#[error("No QMDL store found at path {0}, but can't create a new one due to readonly mode")]
|
||||
NoStoreReadonlyMode(String),
|
||||
#[error("No QMDL store found at path {0}, but can't create a new one due to debug mode")]
|
||||
NoStoreDebugMode(String),
|
||||
}
|
||||
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 {
|
||||
if now.duration_since(last_time) < Duration::from_millis(50) {
|
||||
last_event_time = Some(now);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
last_event_time = Some(now);
|
||||
|
||||
match event {
|
||||
Event::KeyUp => {
|
||||
if let Some(last_keyup_instant) = last_keyup {
|
||||
let elapsed = now.duration_since(last_keyup_instant);
|
||||
|
||||
if elapsed >= Duration::from_millis(100)
|
||||
&& elapsed <= Duration::from_millis(800)
|
||||
{
|
||||
if let Err(e) = diag_tx.send(DiagDeviceCtrlMessage::StopRecording).await
|
||||
{
|
||||
error!("Failed to send StopRecording: {e}");
|
||||
}
|
||||
if let Err(e) =
|
||||
diag_tx.send(DiagDeviceCtrlMessage::StartRecording).await
|
||||
{
|
||||
error!("Failed to send StartRecording: {e}");
|
||||
}
|
||||
last_keyup = None;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
last_keyup = Some(now);
|
||||
}
|
||||
Event::KeyDown => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn parse_event(input: [u8; INPUT_EVENT_SIZE]) -> Event {
|
||||
if input[12] == 0 {
|
||||
Event::KeyUp
|
||||
} else {
|
||||
Event::KeyDown
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_event_keydown_m7350_v5() {
|
||||
let input = [
|
||||
0x57, 0x6c, 0x09, 0x00, 0x7c, 0xfb, 0x03, 0x00, 0x01, 0x00, 0x74, 0x00, 0x01, 0x00,
|
||||
0x00, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
];
|
||||
assert!(matches!(parse_event(input), Event::KeyDown));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_event_keyup_m7350_v5() {
|
||||
let input = [
|
||||
0x57, 0x6c, 0x09, 0x00, 0x1b, 0x15, 0x05, 0x00, 0x01, 0x00, 0x74, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
];
|
||||
assert!(matches!(parse_event(input), Event::KeyUp));
|
||||
}
|
||||
}
|
||||
310
daemon/src/main.rs
Normal file
@@ -0,0 +1,310 @@
|
||||
mod analysis;
|
||||
mod config;
|
||||
mod diag;
|
||||
mod display;
|
||||
mod error;
|
||||
mod key_input;
|
||||
mod pcap;
|
||||
mod qmdl_store;
|
||||
mod server;
|
||||
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::diag::run_diag_read_thread;
|
||||
use crate::error::RayhunterError;
|
||||
use crate::pcap::get_pcap;
|
||||
use crate::qmdl_store::RecordingStore;
|
||||
use crate::server::{ServerState, get_config, get_qmdl, get_zip, serve_static, set_config};
|
||||
use crate::stats::{get_qmdl_manifest, get_system_stats};
|
||||
|
||||
use analysis::{
|
||||
AnalysisCtrlMessage, AnalysisStatus, get_analysis_status, run_analysis_thread, start_analysis,
|
||||
};
|
||||
use axum::Router;
|
||||
use axum::response::Redirect;
|
||||
use axum::routing::{get, post};
|
||||
use diag::{
|
||||
DiagDeviceCtrlMessage, delete_all_recordings, delete_recording, get_analysis_report,
|
||||
start_recording, stop_recording,
|
||||
};
|
||||
use log::{error, info};
|
||||
use qmdl_store::RecordingStoreError;
|
||||
use rayhunter::Device;
|
||||
use rayhunter::diag_device::DiagDevice;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::select;
|
||||
use tokio::sync::mpsc::{self, Sender};
|
||||
use tokio::sync::{RwLock, oneshot};
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
type AppRouter = Router<Arc<ServerState>>;
|
||||
|
||||
fn get_router() -> AppRouter {
|
||||
Router::new()
|
||||
.route("/api/pcap/{name}", get(get_pcap))
|
||||
.route("/api/qmdl/{name}", get(get_qmdl))
|
||||
.route("/api/zip/{name}", get(get_zip))
|
||||
.route("/api/system-stats", get(get_system_stats))
|
||||
.route("/api/qmdl-manifest", get(get_qmdl_manifest))
|
||||
.route("/api/start-recording", post(start_recording))
|
||||
.route("/api/stop-recording", post(stop_recording))
|
||||
.route("/api/delete-recording/{name}", post(delete_recording))
|
||||
.route("/api/delete-all-recordings", post(delete_all_recordings))
|
||||
.route("/api/analysis-report/{name}", get(get_analysis_report))
|
||||
.route("/api/analysis", get(get_analysis_status))
|
||||
.route("/api/analysis/{name}", post(start_analysis))
|
||||
.route("/api/config", get(get_config))
|
||||
.route("/api/config", post(set_config))
|
||||
.route("/", get(|| async { Redirect::permanent("/index.html") }))
|
||||
.route("/{*path}", get(serve_static))
|
||||
}
|
||||
|
||||
// Runs the axum server, taking all the elements needed to build up our
|
||||
// ServerState and a oneshot Receiver that'll fire when it's time to shutdown
|
||||
// (i.e. user hit ctrl+c)
|
||||
async fn run_server(
|
||||
task_tracker: &TaskTracker,
|
||||
state: Arc<ServerState>,
|
||||
server_shutdown_rx: oneshot::Receiver<()>,
|
||||
) -> JoinHandle<()> {
|
||||
info!("spinning up server");
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], state.config.port));
|
||||
let listener = TcpListener::bind(&addr).await.unwrap();
|
||||
let app = get_router().with_state(state);
|
||||
|
||||
task_tracker.spawn(async move {
|
||||
info!("The orca is hunting for stingrays...");
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(server_shutdown_signal(server_shutdown_rx))
|
||||
.await
|
||||
.unwrap();
|
||||
})
|
||||
}
|
||||
|
||||
async fn server_shutdown_signal(server_shutdown_rx: oneshot::Receiver<()>) {
|
||||
server_shutdown_rx.await.unwrap();
|
||||
info!("Server received shutdown signal, exiting...");
|
||||
}
|
||||
|
||||
// Loads a RecordingStore if one exists, and if not, only create one if we're
|
||||
// not in debug mode. If we fail to parse the manifest AND we're not in debug
|
||||
// mode, try to recover the manifest from the existing QMDL files
|
||||
async fn init_qmdl_store(config: &config::Config) -> Result<RecordingStore, RayhunterError> {
|
||||
let store_exists = RecordingStore::exists(&config.qmdl_store_path).await?;
|
||||
if config.debug_mode {
|
||||
if store_exists {
|
||||
Ok(RecordingStore::load(&config.qmdl_store_path).await?)
|
||||
} else {
|
||||
Err(RayhunterError::NoStoreDebugMode(
|
||||
config.qmdl_store_path.clone(),
|
||||
))
|
||||
}
|
||||
} else if store_exists {
|
||||
match RecordingStore::load(&config.qmdl_store_path).await {
|
||||
Ok(store) => Ok(store),
|
||||
Err(RecordingStoreError::ParseManifestError(err)) => {
|
||||
error!("failed to parse QMDL manifest: {err}");
|
||||
info!("recovering manifest from existing QMDL files...");
|
||||
Ok(RecordingStore::recover(&config.qmdl_store_path).await?)
|
||||
}
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
} else {
|
||||
Ok(RecordingStore::create(&config.qmdl_store_path).await?)
|
||||
}
|
||||
}
|
||||
|
||||
// Start a thread that'll track when user hits ctrl+c. When that happens,
|
||||
// trigger various cleanup tasks, including sending signals to other threads to
|
||||
// shutdown
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn run_shutdown_thread(
|
||||
task_tracker: &TaskTracker,
|
||||
diag_device_sender: Sender<DiagDeviceCtrlMessage>,
|
||||
daemon_restart_rx: oneshot::Receiver<()>,
|
||||
should_restart_flag: Arc<AtomicBool>,
|
||||
server_shutdown_tx: oneshot::Sender<()>,
|
||||
maybe_ui_shutdown_tx: Option<oneshot::Sender<()>>,
|
||||
maybe_key_input_shutdown_tx: Option<oneshot::Sender<()>>,
|
||||
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
|
||||
analysis_tx: Sender<AnalysisCtrlMessage>,
|
||||
) -> JoinHandle<Result<(), RayhunterError>> {
|
||||
info!("create shutdown thread");
|
||||
|
||||
task_tracker.spawn(async move {
|
||||
select! {
|
||||
res = tokio::signal::ctrl_c() => {
|
||||
if let Err(err) = res {
|
||||
error!("Unable to listen for shutdown signal: {err}");
|
||||
}
|
||||
|
||||
should_restart_flag.store(false, Ordering::Relaxed);
|
||||
}
|
||||
res = daemon_restart_rx => {
|
||||
if let Err(err) = res {
|
||||
error!("Unable to listen for shutdown signal: {err}");
|
||||
}
|
||||
|
||||
should_restart_flag.store(true, Ordering::Relaxed);
|
||||
}
|
||||
};
|
||||
|
||||
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||
if qmdl_store.current_entry.is_some() {
|
||||
info!("Closing current QMDL entry...");
|
||||
qmdl_store.close_current_entry().await?;
|
||||
info!("Done!");
|
||||
}
|
||||
|
||||
server_shutdown_tx
|
||||
.send(())
|
||||
.expect("couldn't send server shutdown signal");
|
||||
if let Some(ui_shutdown_tx) = maybe_ui_shutdown_tx {
|
||||
let _ = ui_shutdown_tx.send(());
|
||||
}
|
||||
if let Some(key_input_shutdown_tx) = maybe_key_input_shutdown_tx {
|
||||
let _ = key_input_shutdown_tx.send(());
|
||||
}
|
||||
diag_device_sender
|
||||
.send(DiagDeviceCtrlMessage::Exit)
|
||||
.await
|
||||
.expect("couldn't send Exit message to diag thread");
|
||||
analysis_tx
|
||||
.send(AnalysisCtrlMessage::Exit)
|
||||
.await
|
||||
.expect("couldn't send Exit message to analysis thread");
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), RayhunterError> {
|
||||
env_logger::init();
|
||||
|
||||
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
|
||||
// eventually await all of them ending
|
||||
let task_tracker = TaskTracker::new();
|
||||
println!("R A Y H U N T E R 🐳");
|
||||
|
||||
let store = init_qmdl_store(&config).await?;
|
||||
let analysis_status = AnalysisStatus::new(&store);
|
||||
let qmdl_store_lock = Arc::new(RwLock::new(store));
|
||||
let (diag_tx, diag_rx) = mpsc::channel::<DiagDeviceCtrlMessage>(1);
|
||||
let (ui_update_tx, ui_update_rx) = mpsc::channel::<display::DisplayState>(1);
|
||||
let (analysis_tx, analysis_rx) = mpsc::channel::<AnalysisCtrlMessage>(5);
|
||||
let mut maybe_ui_shutdown_tx = None;
|
||||
let mut maybe_key_input_shutdown_tx = None;
|
||||
if !config.debug_mode {
|
||||
let (ui_shutdown_tx, ui_shutdown_rx) = oneshot::channel();
|
||||
maybe_ui_shutdown_tx = Some(ui_shutdown_tx);
|
||||
info!("Using configuration for device: {0:?}", config.device);
|
||||
let mut dev = DiagDevice::new(&config.device)
|
||||
.await
|
||||
.map_err(RayhunterError::DiagInitError)?;
|
||||
dev.config_logs()
|
||||
.await
|
||||
.map_err(RayhunterError::DiagInitError)?;
|
||||
|
||||
info!("Starting Diag Thread");
|
||||
run_diag_read_thread(
|
||||
&task_tracker,
|
||||
dev,
|
||||
diag_rx,
|
||||
ui_update_tx.clone(),
|
||||
qmdl_store_lock.clone(),
|
||||
analysis_tx.clone(),
|
||||
config.analyzers.clone(),
|
||||
);
|
||||
info!("Starting UI");
|
||||
|
||||
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,
|
||||
};
|
||||
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 analysis_status_lock = Arc::new(RwLock::new(analysis_status));
|
||||
run_analysis_thread(
|
||||
&task_tracker,
|
||||
analysis_rx,
|
||||
qmdl_store_lock.clone(),
|
||||
analysis_status_lock.clone(),
|
||||
config.analyzers.clone(),
|
||||
);
|
||||
let should_restart_flag = Arc::new(AtomicBool::new(false));
|
||||
|
||||
run_shutdown_thread(
|
||||
&task_tracker,
|
||||
diag_tx.clone(),
|
||||
daemon_restart_rx,
|
||||
should_restart_flag.clone(),
|
||||
server_shutdown_tx,
|
||||
maybe_ui_shutdown_tx,
|
||||
maybe_key_input_shutdown_tx,
|
||||
qmdl_store_lock.clone(),
|
||||
analysis_tx.clone(),
|
||||
);
|
||||
let state = Arc::new(ServerState {
|
||||
config_path: args.config_path.clone(),
|
||||
config,
|
||||
qmdl_store_lock: qmdl_store_lock.clone(),
|
||||
diag_device_ctrl_sender: diag_tx,
|
||||
ui_update_sender: ui_update_tx,
|
||||
analysis_status_lock,
|
||||
analysis_sender: analysis_tx,
|
||||
daemon_restart_tx: Arc::new(RwLock::new(Some(daemon_restart_tx))),
|
||||
});
|
||||
run_server(&task_tracker, state, server_shutdown_rx).await;
|
||||
|
||||
task_tracker.close();
|
||||
task_tracker.wait().await;
|
||||
|
||||
info!("see you space cowboy...");
|
||||
Ok(should_restart_flag.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_get_router() {
|
||||
// assert that creating the router does not panic from invalid route patterns.
|
||||
let _ = get_router();
|
||||
}
|
||||
}
|
||||
93
daemon/src/pcap.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use crate::ServerState;
|
||||
|
||||
use anyhow::Error;
|
||||
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 log::error;
|
||||
use rayhunter::diag::DataType;
|
||||
use rayhunter::gsmtap_parser;
|
||||
use rayhunter::pcap::GsmtapPcapWriter;
|
||||
use rayhunter::qmdl::QmdlReader;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncRead, AsyncWrite, duplex};
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
// Streams a pcap file chunk-by-chunk to the client by reading the QMDL data
|
||||
// written so far. This is done by spawning a thread which streams chunks of
|
||||
// pcap data to a channel that's piped to the client.
|
||||
pub async fn get_pcap(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
Path(mut qmdl_name): Path<String>,
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
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((
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("couldn't find manifest entry with name {qmdl_name}"),
|
||||
))?;
|
||||
if entry.qmdl_size_bytes == 0 {
|
||||
return Err((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"QMDL file is empty, try again in a bit!".to_string(),
|
||||
));
|
||||
}
|
||||
let qmdl_size_bytes = entry.qmdl_size_bytes;
|
||||
let qmdl_file = qmdl_store
|
||||
.open_entry_qmdl(entry_index)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?;
|
||||
// the QMDL reader should stop at the last successfully written data chunk
|
||||
// (entry.size_bytes)
|
||||
let (reader, writer) = duplex(1024);
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = generate_pcap_data(writer, qmdl_file, qmdl_size_bytes).await {
|
||||
error!("failed to generate PCAP: {e:?}");
|
||||
}
|
||||
});
|
||||
|
||||
let headers = [(CONTENT_TYPE, "application/vnd.tcpdump.pcap")];
|
||||
let body = Body::from_stream(ReaderStream::new(reader));
|
||||
Ok((headers, body).into_response())
|
||||
}
|
||||
|
||||
pub async fn generate_pcap_data<R, W>(
|
||||
writer: W,
|
||||
qmdl_file: R,
|
||||
qmdl_size_bytes: usize,
|
||||
) -> Result<(), Error>
|
||||
where
|
||||
W: AsyncWrite + Unpin + Send,
|
||||
R: AsyncRead + Unpin,
|
||||
{
|
||||
let mut pcap_writer = GsmtapPcapWriter::new(writer).await?;
|
||||
pcap_writer.write_iface_header().await?;
|
||||
|
||||
let mut reader = QmdlReader::new(qmdl_file, Some(qmdl_size_bytes));
|
||||
while let Some(container) = reader.get_next_messages_container().await? {
|
||||
if container.data_type != DataType::UserSpace {
|
||||
continue;
|
||||
}
|
||||
|
||||
for maybe_msg in container.into_messages() {
|
||||
match maybe_msg {
|
||||
Ok(msg) => {
|
||||
let maybe_gsmtap_msg = gsmtap_parser::parse(msg)?;
|
||||
if let Some((timestamp, gsmtap_msg)) = maybe_gsmtap_msg {
|
||||
pcap_writer
|
||||
.write_gsmtap_message(gsmtap_msg, timestamp)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Err(e) => error!("error parsing message: {e:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
518
daemon/src/qmdl_store.rs
Normal file
@@ -0,0 +1,518 @@
|
||||
use std::io::{self, ErrorKind};
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::{DateTime, Local};
|
||||
use log::{info, warn};
|
||||
use rayhunter::util::RuntimeMetadata;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use tokio::{
|
||||
fs::{self, File, OpenOptions, try_exists},
|
||||
io::AsyncWriteExt,
|
||||
};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum RecordingStoreError {
|
||||
#[error("Can't close an entry when there's no current entry")]
|
||||
NoCurrentEntry,
|
||||
#[error("An entry with that name doesn't exist")]
|
||||
NoSuchEntryError,
|
||||
#[error("Couldn't create file: {0}")]
|
||||
CreateFileError(tokio::io::Error),
|
||||
#[error("Couldn't read file: {0}")]
|
||||
ReadFileError(tokio::io::Error),
|
||||
#[error("Couldn't delete file: {0}")]
|
||||
DeleteFileError(tokio::io::Error),
|
||||
#[error("Couldn't open directory at path: {0}")]
|
||||
OpenDirError(tokio::io::Error),
|
||||
#[error("Couldn't read manifest file: {0}")]
|
||||
ReadManifestError(tokio::io::Error),
|
||||
#[error("Couldn't write manifest file: {0}")]
|
||||
WriteManifestError(tokio::io::Error),
|
||||
#[error("Couldn't parse QMDL store manifest file: {0}")]
|
||||
ParseManifestError(toml::de::Error),
|
||||
}
|
||||
|
||||
pub struct RecordingStore {
|
||||
pub path: PathBuf,
|
||||
pub manifest: Manifest,
|
||||
pub current_entry: Option<usize>, // index into manifest
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, PartialEq, Debug)]
|
||||
pub struct Manifest {
|
||||
pub entries: Vec<ManifestEntry>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, PartialEq, Debug)]
|
||||
pub struct ManifestEntry {
|
||||
pub name: String,
|
||||
pub start_time: DateTime<Local>,
|
||||
pub last_message_time: Option<DateTime<Local>>,
|
||||
pub qmdl_size_bytes: usize,
|
||||
pub rayhunter_version: Option<String>,
|
||||
pub system_os: Option<String>,
|
||||
pub arch: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq)]
|
||||
pub enum EntryType {
|
||||
Current,
|
||||
Past,
|
||||
}
|
||||
|
||||
impl ManifestEntry {
|
||||
fn new() -> Self {
|
||||
let now = Local::now();
|
||||
let metadata = RuntimeMetadata::new();
|
||||
ManifestEntry {
|
||||
name: format!("{}", now.timestamp()),
|
||||
start_time: now,
|
||||
last_message_time: None,
|
||||
qmdl_size_bytes: 0,
|
||||
rayhunter_version: Some(metadata.rayhunter_version),
|
||||
system_os: Some(metadata.system_os),
|
||||
arch: Some(metadata.arch),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_qmdl_filepath<P: AsRef<Path>>(&self, path: P) -> PathBuf {
|
||||
let mut filepath = path.as_ref().join(&self.name);
|
||||
filepath.set_extension("qmdl");
|
||||
filepath
|
||||
}
|
||||
|
||||
pub fn get_analysis_filepath<P: AsRef<Path>>(&self, path: P) -> PathBuf {
|
||||
let mut filepath = path.as_ref().join(&self.name);
|
||||
filepath.set_extension("ndjson");
|
||||
filepath
|
||||
}
|
||||
}
|
||||
|
||||
impl RecordingStore {
|
||||
// Returns whether a directory with a "manifest.toml" exists at the given
|
||||
// path (though doesn't check if that manifest is valid)
|
||||
pub async fn exists<P>(path: P) -> Result<bool, RecordingStoreError>
|
||||
where
|
||||
P: AsRef<Path>,
|
||||
{
|
||||
let manifest_path = path.as_ref().join("manifest.toml");
|
||||
let dir_exists = try_exists(path)
|
||||
.await
|
||||
.map_err(RecordingStoreError::OpenDirError)?;
|
||||
let manifest_exists = try_exists(manifest_path)
|
||||
.await
|
||||
.map_err(RecordingStoreError::ReadManifestError)?;
|
||||
Ok(dir_exists && manifest_exists)
|
||||
}
|
||||
|
||||
// Loads an existing RecordingStore at the given path. Errors if no store exists,
|
||||
// or if it's malformed.
|
||||
pub async fn load<P>(path: P) -> Result<Self, RecordingStoreError>
|
||||
where
|
||||
P: AsRef<Path>,
|
||||
{
|
||||
let path: PathBuf = path.as_ref().to_path_buf();
|
||||
let manifest = RecordingStore::read_manifest(&path).await?;
|
||||
Ok(RecordingStore {
|
||||
path,
|
||||
manifest,
|
||||
current_entry: None,
|
||||
})
|
||||
}
|
||||
|
||||
// Creates a new RecordingStore at the given path. This involves creating a dir
|
||||
// and writing an empty manifest.
|
||||
pub async fn create<P>(path: P) -> Result<Self, RecordingStoreError>
|
||||
where
|
||||
P: AsRef<Path>,
|
||||
{
|
||||
fs::create_dir_all(&path)
|
||||
.await
|
||||
.map_err(RecordingStoreError::OpenDirError)?;
|
||||
|
||||
let mut store = RecordingStore {
|
||||
path: path.as_ref().to_owned(),
|
||||
manifest: Manifest {
|
||||
entries: Vec::new(),
|
||||
},
|
||||
current_entry: None,
|
||||
};
|
||||
|
||||
store.write_manifest().await?;
|
||||
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>
|
||||
where
|
||||
P: AsRef<Path>,
|
||||
{
|
||||
let manifest_path = path.as_ref().join("manifest.toml");
|
||||
let file_contents = fs::read_to_string(&manifest_path)
|
||||
.await
|
||||
.map_err(RecordingStoreError::ReadManifestError)?;
|
||||
toml::from_str(&file_contents).map_err(RecordingStoreError::ParseManifestError)
|
||||
}
|
||||
|
||||
// Closes the current entry (if needed), creates a new entry based on the
|
||||
// current time, and updates the manifest. Returns a tuple of the entry's
|
||||
// newly created QMDL file and analysis file.
|
||||
pub async fn new_entry(&mut self) -> Result<(File, File), RecordingStoreError> {
|
||||
// if we've already got an entry open, close it
|
||||
if self.current_entry.is_some() {
|
||||
self.close_current_entry().await?;
|
||||
}
|
||||
let new_entry = ManifestEntry::new();
|
||||
let qmdl_filepath = new_entry.get_qmdl_filepath(&self.path);
|
||||
let qmdl_file = File::create(&qmdl_filepath)
|
||||
.await
|
||||
.map_err(RecordingStoreError::CreateFileError)?;
|
||||
let analysis_filepath = new_entry.get_analysis_filepath(&self.path);
|
||||
let analysis_file = File::create(&analysis_filepath)
|
||||
.await
|
||||
.map_err(RecordingStoreError::CreateFileError)?;
|
||||
self.manifest.entries.push(new_entry);
|
||||
self.current_entry = Some(self.manifest.entries.len() - 1);
|
||||
self.write_manifest().await?;
|
||||
Ok((qmdl_file, analysis_file))
|
||||
}
|
||||
|
||||
// Returns the corresponding QMDL file for a given entry
|
||||
pub async fn open_entry_qmdl(&self, entry_index: usize) -> Result<File, RecordingStoreError> {
|
||||
let entry = &self.manifest.entries[entry_index];
|
||||
File::open(entry.get_qmdl_filepath(&self.path))
|
||||
.await
|
||||
.map_err(RecordingStoreError::ReadFileError)
|
||||
}
|
||||
|
||||
// Returns the corresponding QMDL file for a given entry
|
||||
pub async fn open_entry_analysis(
|
||||
&self,
|
||||
entry_index: usize,
|
||||
) -> Result<File, RecordingStoreError> {
|
||||
let entry = &self.manifest.entries[entry_index];
|
||||
File::open(entry.get_analysis_filepath(&self.path))
|
||||
.await
|
||||
.map_err(RecordingStoreError::ReadFileError)
|
||||
}
|
||||
|
||||
pub async fn clear_and_open_entry_analysis(
|
||||
&mut self,
|
||||
entry_index: usize,
|
||||
) -> Result<File, RecordingStoreError> {
|
||||
let entry = &self.manifest.entries[entry_index];
|
||||
let file = OpenOptions::new()
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(entry.get_analysis_filepath(&self.path))
|
||||
.await
|
||||
.map_err(RecordingStoreError::ReadFileError)?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
// Unsets the current entry
|
||||
pub async fn close_current_entry(&mut self) -> Result<(), RecordingStoreError> {
|
||||
match self.current_entry {
|
||||
Some(_) => {
|
||||
self.current_entry = None;
|
||||
Ok(())
|
||||
}
|
||||
None => Err(RecordingStoreError::NoCurrentEntry),
|
||||
}
|
||||
}
|
||||
|
||||
// Sets the given entry's size and updates the last_message_time to now, updating the manifest
|
||||
pub async fn update_entry_qmdl_size(
|
||||
&mut self,
|
||||
entry_index: usize,
|
||||
size_bytes: usize,
|
||||
) -> Result<(), RecordingStoreError> {
|
||||
self.manifest.entries[entry_index].qmdl_size_bytes = size_bytes;
|
||||
self.manifest.entries[entry_index].last_message_time = Some(Local::now());
|
||||
self.write_manifest().await
|
||||
}
|
||||
|
||||
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 mut manifest_tmp_file = File::create(&tmp_path)
|
||||
.await
|
||||
.map_err(RecordingStoreError::WriteManifestError)?;
|
||||
|
||||
let manifest_contents =
|
||||
toml::to_string_pretty(&self.manifest).expect("failed to serialize manifest");
|
||||
manifest_tmp_file
|
||||
.write_all(manifest_contents.as_bytes())
|
||||
.await
|
||||
.map_err(RecordingStoreError::WriteManifestError)?;
|
||||
|
||||
fs::rename(tmp_path, self.path.join("manifest.toml"))
|
||||
.await
|
||||
.map_err(RecordingStoreError::WriteManifestError)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Finds an entry by filename
|
||||
pub fn entry_for_name(&self, name: &str) -> Option<(usize, &ManifestEntry)> {
|
||||
let entry_index = self
|
||||
.manifest
|
||||
.entries
|
||||
.iter()
|
||||
.position(|entry| entry.name == name)?;
|
||||
Some((entry_index, &self.manifest.entries[entry_index]))
|
||||
}
|
||||
|
||||
pub fn get_current_entry(&self) -> Option<(usize, &ManifestEntry)> {
|
||||
let entry_index = self.current_entry?;
|
||||
Some((entry_index, &self.manifest.entries[entry_index]))
|
||||
}
|
||||
|
||||
pub async fn delete_entry(&mut self, name: &str) -> Result<EntryType, RecordingStoreError> {
|
||||
let entry_to_delete_idx = self
|
||||
.manifest
|
||||
.entries
|
||||
.iter()
|
||||
.position(|entry| entry.name == name)
|
||||
.ok_or(RecordingStoreError::NoSuchEntryError)?;
|
||||
let is_current = match self.current_entry {
|
||||
Some(current_entry) if current_entry == entry_to_delete_idx => {
|
||||
self.close_current_entry().await?;
|
||||
EntryType::Current
|
||||
}
|
||||
Some(current_entry) => {
|
||||
self.current_entry = Some(current_entry - 1);
|
||||
EntryType::Past
|
||||
}
|
||||
None => EntryType::Past,
|
||||
};
|
||||
let entry_to_delete = self.manifest.entries.remove(entry_to_delete_idx);
|
||||
self.write_manifest().await?;
|
||||
let qmdl_filepath = entry_to_delete.get_qmdl_filepath(&self.path);
|
||||
let analysis_filepath = entry_to_delete.get_analysis_filepath(&self.path);
|
||||
remove_file_if_exists(&qmdl_filepath)
|
||||
.await
|
||||
.map_err(RecordingStoreError::DeleteFileError)?;
|
||||
remove_file_if_exists(&analysis_filepath)
|
||||
.await
|
||||
.map_err(RecordingStoreError::DeleteFileError)?;
|
||||
Ok(is_current)
|
||||
}
|
||||
|
||||
pub async fn delete_all_entries(&mut self) -> Result<(), RecordingStoreError> {
|
||||
if self.current_entry.is_some() {
|
||||
self.close_current_entry().await?;
|
||||
}
|
||||
|
||||
let mut keep = Vec::new();
|
||||
|
||||
for entry in &self.manifest.entries {
|
||||
let qmdl_filepath = entry.get_qmdl_filepath(&self.path);
|
||||
let analysis_filepath = entry.get_analysis_filepath(&self.path);
|
||||
|
||||
if let Err(e) = remove_file_if_exists(&qmdl_filepath).await {
|
||||
log::warn!("failed to remove {qmdl_filepath:?}: {e:?}");
|
||||
keep.push(true);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(e) = remove_file_if_exists(&analysis_filepath).await {
|
||||
log::warn!("failed to remove {analysis_filepath:?}: {e:?}");
|
||||
keep.push(true);
|
||||
continue;
|
||||
}
|
||||
|
||||
keep.push(false);
|
||||
}
|
||||
|
||||
let mut keep_iter = keep.into_iter();
|
||||
self.manifest.entries.retain(|_| keep_iter.next().unwrap());
|
||||
self.write_manifest().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_file_if_exists(path: &Path) -> Result<(), io::Error> {
|
||||
match tokio::fs::remove_file(path).await {
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
|
||||
res => res,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::{Builder, TempDir};
|
||||
|
||||
fn make_temp_dir() -> TempDir {
|
||||
Builder::new().prefix("qmdl_store_test").tempdir().unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_from_empty_dir() {
|
||||
let dir = make_temp_dir();
|
||||
assert!(!RecordingStore::exists(dir.path()).await.unwrap());
|
||||
let _created_store = RecordingStore::create(dir.path()).await.unwrap();
|
||||
assert!(RecordingStore::exists(dir.path()).await.unwrap());
|
||||
let loaded_store = RecordingStore::load(dir.path()).await.unwrap();
|
||||
assert_eq!(loaded_store.manifest.entries.len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_creating_updating_and_closing_entries() {
|
||||
let dir = make_temp_dir();
|
||||
let mut store = RecordingStore::create(dir.path()).await.unwrap();
|
||||
let _ = store.new_entry().await.unwrap();
|
||||
let entry_index = store.current_entry.unwrap();
|
||||
assert_eq!(
|
||||
RecordingStore::read_manifest(dir.path()).await.unwrap(),
|
||||
store.manifest
|
||||
);
|
||||
assert!(
|
||||
store.manifest.entries[entry_index]
|
||||
.last_message_time
|
||||
.is_none()
|
||||
);
|
||||
|
||||
store
|
||||
.update_entry_qmdl_size(entry_index, 1000)
|
||||
.await
|
||||
.unwrap();
|
||||
let (entry_index, entry) = store
|
||||
.entry_for_name(&store.manifest.entries[entry_index].name)
|
||||
.unwrap();
|
||||
assert!(entry.last_message_time.is_some());
|
||||
assert_eq!(store.manifest.entries[entry_index].qmdl_size_bytes, 1000);
|
||||
assert_eq!(
|
||||
RecordingStore::read_manifest(dir.path()).await.unwrap(),
|
||||
store.manifest
|
||||
);
|
||||
|
||||
store.close_current_entry().await.unwrap();
|
||||
assert!(matches!(
|
||||
store.close_current_entry().await,
|
||||
Err(RecordingStoreError::NoCurrentEntry)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_on_existing_store() {
|
||||
let dir = make_temp_dir();
|
||||
let mut store = RecordingStore::create(dir.path()).await.unwrap();
|
||||
let _ = store.new_entry().await.unwrap();
|
||||
let entry_index = store.current_entry.unwrap();
|
||||
store
|
||||
.update_entry_qmdl_size(entry_index, 1000)
|
||||
.await
|
||||
.unwrap();
|
||||
let store = RecordingStore::create(dir.path()).await.unwrap();
|
||||
assert_eq!(store.manifest.entries.len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_repeated_new_entries() {
|
||||
let dir = make_temp_dir();
|
||||
let mut store = RecordingStore::create(dir.path()).await.unwrap();
|
||||
let _ = store.new_entry().await.unwrap();
|
||||
let entry_index = store.current_entry.unwrap();
|
||||
let _ = store.new_entry().await.unwrap();
|
||||
let new_entry_index = store.current_entry.unwrap();
|
||||
assert_ne!(entry_index, new_entry_index);
|
||||
assert_eq!(store.manifest.entries.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_all_entries() {
|
||||
let dir = make_temp_dir();
|
||||
let mut store = RecordingStore::create(dir.path()).await.unwrap();
|
||||
let _ = store.new_entry().await.unwrap();
|
||||
assert!(store.current_entry.is_some());
|
||||
|
||||
store.delete_all_entries().await.unwrap();
|
||||
assert!(store.current_entry.is_none());
|
||||
|
||||
// regression test: deleting all entries should also work when there's no current
|
||||
// recording.
|
||||
store.delete_all_entries().await.unwrap();
|
||||
assert!(store.current_entry.is_none());
|
||||
}
|
||||
}
|
||||
348
daemon/src/server.rs
Normal file
@@ -0,0 +1,348 @@
|
||||
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::analysis::{AnalysisCtrlMessage, AnalysisStatus};
|
||||
use crate::config::Config;
|
||||
use crate::pcap::generate_pcap_data;
|
||||
use crate::qmdl_store::RecordingStore;
|
||||
use crate::{DiagDeviceCtrlMessage, display};
|
||||
|
||||
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 ui_update_sender: Sender<display::DisplayState>,
|
||||
pub analysis_status_lock: Arc<RwLock<AnalysisStatus>>,
|
||||
pub analysis_sender: Sender<AnalysisCtrlMessage>,
|
||||
pub daemon_restart_tx: Arc<RwLock<Option<oneshot::Sender<()>>>>,
|
||||
}
|
||||
|
||||
pub async fn get_qmdl(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
Path(qmdl_name): Path<String>,
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
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())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_zip::base::read::mem::ZipFileReader;
|
||||
use axum::extract::{Path, State};
|
||||
use tempfile::TempDir;
|
||||
|
||||
async fn create_test_qmdl_store() -> (TempDir, Arc<RwLock<crate::qmdl_store::RecordingStore>>) {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let store_path = temp_dir.path().to_path_buf();
|
||||
let store = crate::qmdl_store::RecordingStore::create(&store_path)
|
||||
.await
|
||||
.unwrap();
|
||||
(temp_dir, Arc::new(RwLock::new(store)))
|
||||
}
|
||||
|
||||
async fn create_test_entry_with_data(
|
||||
store_lock: &Arc<RwLock<crate::qmdl_store::RecordingStore>>,
|
||||
test_data: &[u8],
|
||||
) -> String {
|
||||
let entry_name = {
|
||||
let mut store = store_lock.write().await;
|
||||
let (mut qmdl_file, _analysis_file) = store.new_entry().await.unwrap();
|
||||
|
||||
if !test_data.is_empty() {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
qmdl_file.write_all(test_data).await.unwrap();
|
||||
qmdl_file.flush().await.unwrap();
|
||||
}
|
||||
|
||||
let current_entry = store.current_entry.unwrap();
|
||||
let entry = &store.manifest.entries[current_entry];
|
||||
let entry_name = entry.name.clone();
|
||||
|
||||
store
|
||||
.update_entry_qmdl_size(current_entry, test_data.len())
|
||||
.await
|
||||
.unwrap();
|
||||
entry_name
|
||||
};
|
||||
|
||||
let mut store = store_lock.write().await;
|
||||
store.close_current_entry().await.unwrap();
|
||||
entry_name
|
||||
}
|
||||
|
||||
fn create_test_server_state(
|
||||
store_lock: Arc<RwLock<crate::qmdl_store::RecordingStore>>,
|
||||
) -> Arc<ServerState> {
|
||||
let (tx, _rx) = tokio::sync::mpsc::channel(1);
|
||||
let (ui_tx, _ui_rx) = tokio::sync::mpsc::channel(1);
|
||||
let (analysis_tx, _analysis_rx) = tokio::sync::mpsc::channel(1);
|
||||
|
||||
let analysis_status = {
|
||||
let store = store_lock.try_read().unwrap();
|
||||
crate::analysis::AnalysisStatus::new(&store)
|
||||
};
|
||||
|
||||
Arc::new(ServerState {
|
||||
config_path: "/tmp/test_config.toml".to_string(),
|
||||
config: Config::default(),
|
||||
qmdl_store_lock: store_lock,
|
||||
diag_device_ctrl_sender: tx,
|
||||
ui_update_sender: ui_tx,
|
||||
analysis_status_lock: Arc::new(RwLock::new(analysis_status)),
|
||||
analysis_sender: analysis_tx,
|
||||
daemon_restart_tx: Arc::new(RwLock::new(None)),
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_zip_success() {
|
||||
let (_temp_dir, store_lock) = create_test_qmdl_store().await;
|
||||
let test_qmdl_data = vec![0x7E, 0x00, 0x00, 0x00, 0x10, 0x00, 0x7E];
|
||||
let entry_name = create_test_entry_with_data(&store_lock, &test_qmdl_data).await;
|
||||
let state = create_test_server_state(store_lock);
|
||||
|
||||
let result = get_zip(State(state), Path(entry_name.clone())).await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let response = result.unwrap();
|
||||
|
||||
let headers = response.headers();
|
||||
assert_eq!(headers.get("content-type").unwrap(), "application/zip");
|
||||
|
||||
let body = response.into_body();
|
||||
let body_bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
|
||||
|
||||
let zip_reader = ZipFileReader::new(body_bytes.to_vec()).await.unwrap();
|
||||
|
||||
let filenames = zip_reader
|
||||
.file()
|
||||
.entries()
|
||||
.iter()
|
||||
.map(|entry| entry.filename().as_str().unwrap().to_owned())
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
assert_eq!(
|
||||
filenames,
|
||||
vec![format!("{entry_name}.qmdl"), format!("{entry_name}.pcapng"),]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use axum::Json;
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use log::error;
|
||||
use rayhunter::util::RuntimeMetadata;
|
||||
use serde::Serialize;
|
||||
use tokio::process::Command;
|
||||
|
||||
@@ -14,6 +15,7 @@ use tokio::process::Command;
|
||||
pub struct SystemStats {
|
||||
pub disk_stats: DiskStats,
|
||||
pub memory_stats: MemoryStats,
|
||||
pub runtime_metadata: RuntimeMetadata,
|
||||
}
|
||||
|
||||
impl SystemStats {
|
||||
@@ -21,6 +23,7 @@ impl SystemStats {
|
||||
Ok(Self {
|
||||
disk_stats: DiskStats::new(qmdl_path).await?,
|
||||
memory_stats: MemoryStats::new().await?,
|
||||
runtime_metadata: RuntimeMetadata::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -65,10 +68,16 @@ pub struct MemoryStats {
|
||||
// runs the given command and returns its stdout as a string
|
||||
async fn get_cmd_output(mut cmd: Command) -> Result<String, String> {
|
||||
let cmd_str = format!("{:?}", &cmd);
|
||||
let output = cmd.output().await
|
||||
let output = cmd
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("error running command {}: {}", &cmd_str, e))?;
|
||||
if !output.status.success() {
|
||||
return Err(format!("command {} failed with exit code {}", &cmd_str, output.status.code().unwrap()));
|
||||
return Err(format!(
|
||||
"command {} failed with exit code {}",
|
||||
&cmd_str,
|
||||
output.status.code().unwrap()
|
||||
));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
}
|
||||
@@ -79,7 +88,8 @@ impl MemoryStats {
|
||||
let mut free_cmd = Command::new("free");
|
||||
free_cmd.arg("-k");
|
||||
let stdout = get_cmd_output(free_cmd).await?;
|
||||
let mut numbers = stdout.split_whitespace()
|
||||
let mut numbers = stdout
|
||||
.split_whitespace()
|
||||
.flat_map(|part| part.parse::<usize>());
|
||||
Ok(Self {
|
||||
total: humanize_kb(numbers.next().ok_or("error parsing free output")?),
|
||||
@@ -91,23 +101,25 @@ impl MemoryStats {
|
||||
|
||||
// turns a number of kilobytes (like 28293) into a human-readable string (like "28.3M")
|
||||
fn humanize_kb(kb: usize) -> String {
|
||||
if kb < 1000{
|
||||
return format!("{}K", kb);
|
||||
if kb < 1000 {
|
||||
return format!("{kb}K");
|
||||
}
|
||||
format!("{:.1}M", kb as f64 / 1024.0)
|
||||
}
|
||||
|
||||
pub async fn get_system_stats(State(state): State<Arc<ServerState>>) -> Result<Json<SystemStats>, (StatusCode, String)> {
|
||||
pub async fn get_system_stats(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
) -> Result<Json<SystemStats>, (StatusCode, String)> {
|
||||
let qmdl_store = state.qmdl_store_lock.read().await;
|
||||
match SystemStats::new(qmdl_store.path.to_str().unwrap()).await {
|
||||
Ok(stats) => Ok(Json(stats)),
|
||||
Err(err) => {
|
||||
error!("error getting system stats: {}", err);
|
||||
error!("error getting system stats: {err}");
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"error getting system stats".to_string()
|
||||
"error getting system stats".to_string(),
|
||||
))
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +129,9 @@ pub struct ManifestStats {
|
||||
pub current_entry: Option<ManifestEntry>,
|
||||
}
|
||||
|
||||
pub async fn get_qmdl_manifest(State(state): State<Arc<ServerState>>) -> Result<Json<ManifestStats>, (StatusCode, String)> {
|
||||
pub async fn get_qmdl_manifest(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
) -> Result<Json<ManifestStats>, (StatusCode, String)> {
|
||||
let qmdl_store = state.qmdl_store_lock.read().await;
|
||||
let mut entries = qmdl_store.manifest.entries.clone();
|
||||
let current_entry = qmdl_store.current_entry.map(|index| entries.remove(index));
|
||||
24
daemon/web/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
1
daemon/web/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
7
daemon/web/.prettierignore
Normal file
@@ -0,0 +1,7 @@
|
||||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
|
||||
# Static Assets
|
||||
static/pico.min.css
|
||||
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
@@ -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
@@ -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
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
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
@@ -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
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents" class="m-4 xl:m-8">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
140
daemon/web/src/lib/analysis.svelte.spec.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AnalysisRowType, EventType, parse_finished_report, Severity } from './analysis.svelte';
|
||||
import { type NewlineDeliminatedJson } from './ndjson';
|
||||
|
||||
const SAMPLE_V1_REPORT_NDJSON: NewlineDeliminatedJson = [
|
||||
{
|
||||
analyzers: [
|
||||
{
|
||||
name: 'Analyzer 1',
|
||||
description: 'A first analyzer',
|
||||
},
|
||||
{
|
||||
name: 'Analyzer 2',
|
||||
description: 'A second analyzer',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
timestamp: '2024-10-08T13:25:43.011689003-07:00',
|
||||
skipped_message_reasons: ['The reason why the message was skipped'],
|
||||
analysis: [],
|
||||
},
|
||||
{
|
||||
timestamp: '2024-10-08T13:25:43.480872496-07:00',
|
||||
skipped_message_reasons: [],
|
||||
analysis: [
|
||||
{
|
||||
timestamp: '2024-08-19T03:33:54.318Z',
|
||||
events: [
|
||||
null,
|
||||
{
|
||||
event_type: { type: 'QualitativeWarning', severity: 'Low' },
|
||||
message: 'Something nasty happened',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
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: { type: 'QualitativeWarning', severity: 'Low' },
|
||||
message: 'Something nasty happened',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
describe('analysis report parsing', () => {
|
||||
it('parses v1 example analysis', () => {
|
||||
const report = parse_finished_report(SAMPLE_V1_REPORT_NDJSON);
|
||||
expect(report.metadata.report_version).toEqual(1);
|
||||
expect(report.metadata.analyzers).toEqual([
|
||||
{
|
||||
name: 'Analyzer 1',
|
||||
description: 'A first analyzer',
|
||||
version: 0,
|
||||
},
|
||||
{
|
||||
name: 'Analyzer 2',
|
||||
description: 'A second analyzer',
|
||||
version: 0,
|
||||
},
|
||||
]);
|
||||
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());
|
||||
if (event !== null && event.type === EventType.Warning) {
|
||||
expect(event.severity).toEqual(Severity.Low);
|
||||
} else {
|
||||
throw 'wrong event type';
|
||||
}
|
||||
} else {
|
||||
throw 'wrong row type';
|
||||
}
|
||||
});
|
||||
|
||||
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());
|
||||
if (event !== null && event.type === EventType.Warning) {
|
||||
expect(event.severity).toEqual(Severity.Low);
|
||||
} else {
|
||||
throw 'wrong event type';
|
||||
}
|
||||
} else {
|
||||
throw 'wrong row type';
|
||||
}
|
||||
});
|
||||
});
|
||||
208
daemon/web/src/lib/analysis.svelte.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
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;
|
||||
if (ndjson.report_version === undefined) {
|
||||
this.report_version = 1;
|
||||
// we consider our legacy (unversioned) heuristics to be v0 --
|
||||
// this'll let us clearly differentiate some known false-positive
|
||||
// results from the pre-versioned era from v1 heuristics
|
||||
this.analyzers.forEach((analyzer) => {
|
||||
analyzer.version = 0;
|
||||
});
|
||||
} else {
|
||||
this.report_version = ndjson.report_version;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 Event = QualitativeWarning | InformationalEvent | null;
|
||||
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;
|
||||
};
|
||||
|
||||
function get_event(event_json: any): Event {
|
||||
if (event_json.event_type.type === 'Informational') {
|
||||
return {
|
||||
type: EventType.Informational,
|
||||
message: event_json.message,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: EventType.Warning,
|
||||
severity:
|
||||
event_json.event_type.severity === 'High'
|
||||
? Severity.High
|
||||
: event_json.event_type.severity === 'Medium'
|
||||
? Severity.Medium
|
||||
: Severity.Low,
|
||||
message: event_json.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function get_v1_rows(row_jsons: any[]): AnalysisRow[] {
|
||||
const rows: AnalysisRow[] = [];
|
||||
for (const row_json of row_jsons) {
|
||||
for (const reason of row_json.skipped_message_reasons) {
|
||||
rows.push({
|
||||
type: AnalysisRowType.Skipped,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
for (const analysis_json of row_json.analysis) {
|
||||
const events: Event[] = analysis_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(analysis_json.timestamp),
|
||||
events,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function get_v2_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.type === EventType.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]);
|
||||
let rows;
|
||||
if (metadata.report_version === 1) {
|
||||
rows = get_v1_rows(report_json.slice(1));
|
||||
} else {
|
||||
rows = get_v2_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);
|
||||
}
|
||||
64
daemon/web/src/lib/analysisManager.svelte.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { get_report, type AnalysisReport } from './analysis.svelte';
|
||||
import { req } from './utils.svelte';
|
||||
|
||||
export enum AnalysisStatus {
|
||||
// rayhunter is currently analyzing this entry (note that this is distinct
|
||||
// from the currently-recording entry)
|
||||
Running,
|
||||
// this entry is queued to be analyzed
|
||||
Queued,
|
||||
// analysis is finished, and the new report can be accessed
|
||||
Finished,
|
||||
}
|
||||
|
||||
type AnalysisStatusJson = {
|
||||
running: string | null;
|
||||
queued: string[];
|
||||
finished: string[];
|
||||
};
|
||||
|
||||
export type AnalysisResult = {
|
||||
name: string;
|
||||
status: AnalysisStatus;
|
||||
};
|
||||
|
||||
export class AnalysisManager {
|
||||
public status: Map<string, AnalysisStatus> = new Map();
|
||||
public reports: Map<string, AnalysisReport | string> = new Map();
|
||||
|
||||
public async run_analysis(name: string) {
|
||||
await req('POST', `/api/analysis/${name}`);
|
||||
this.status.set(name, AnalysisStatus.Queued);
|
||||
this.reports.delete(name);
|
||||
}
|
||||
|
||||
public async update() {
|
||||
const status: AnalysisStatusJson = JSON.parse(await req('GET', '/api/analysis'));
|
||||
if (status.running) {
|
||||
this.status.set(status.running, AnalysisStatus.Running);
|
||||
}
|
||||
|
||||
for (const entry of status.queued) {
|
||||
this.status.set(entry, AnalysisStatus.Queued);
|
||||
}
|
||||
|
||||
for (const entry of status.finished) {
|
||||
// if entry was already finished, nothing to do
|
||||
if (this.status.get(entry) === AnalysisStatus.Finished) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.status.set(entry, AnalysisStatus.Finished);
|
||||
|
||||
// fetch the analysis report
|
||||
this.reports.delete(entry);
|
||||
get_report(entry)
|
||||
.then((report) => {
|
||||
this.reports.set(entry, report);
|
||||
})
|
||||
.catch((err) => {
|
||||
this.reports.set(entry, `Failed to get analysis: ${err}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
64
daemon/web/src/lib/components/AnalysisStatus.svelte
Normal file
@@ -0,0 +1,64 @@
|
||||
<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(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() || 0) < 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>
|
||||
101
daemon/web/src/lib/components/AnalysisTable.svelte
Normal file
@@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
import { AnalysisRowType, EventType, 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}
|
||||
<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.filter((e) => e !== null) as event, i}
|
||||
{@const analyzer = analyzers[i]}
|
||||
<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">{analyzer.name} v{analyzer.version}</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">{analyzer.name} v{analyzer.version}</td>
|
||||
<td class="p-2">{event.message}</td>
|
||||
<td class="p-2">Info</td>
|
||||
{/if}
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
{/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}
|
||||
42
daemon/web/src/lib/components/AnalysisView.svelte
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { type ReportMetadata } from '$lib/analysis.svelte';
|
||||
import type { ManifestEntry } from '$lib/manifest.svelte';
|
||||
import AnalysisTable from './AnalysisTable.svelte';
|
||||
let {
|
||||
entry,
|
||||
}: {
|
||||
entry: ManifestEntry;
|
||||
} = $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 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>
|
||||
258
daemon/web/src/lib/components/ConfigForm.svelte
Normal file
@@ -0,0 +1,258 @@
|
||||
<script lang="ts">
|
||||
import { get_config, set_config, type Config } from '../utils.svelte';
|
||||
|
||||
let config = $state<Config | null>(null);
|
||||
|
||||
let loading = $state(false);
|
||||
let saving = $state(false);
|
||||
let message = $state('');
|
||||
let messageType = $state<'success' | 'error' | null>(null);
|
||||
let showConfig = $state(false);
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
loading = true;
|
||||
config = await get_config();
|
||||
message = '';
|
||||
messageType = null;
|
||||
} catch (error) {
|
||||
message = `Failed to load config: ${error}`;
|
||||
messageType = 'error';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
if (!config) return;
|
||||
|
||||
try {
|
||||
saving = true;
|
||||
await set_config(config);
|
||||
message =
|
||||
'Config saved successfully! Rayhunter is restarting now. Reload the page in a few seconds.';
|
||||
messageType = 'success';
|
||||
} catch (error) {
|
||||
message = `Failed to save config: ${error}`;
|
||||
messageType = 'error';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Load config when first shown
|
||||
$effect(() => {
|
||||
if (showConfig && !config) {
|
||||
loadConfig();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-md p-6 m-4">
|
||||
<button
|
||||
class="w-full flex justify-between items-center text-xl font-bold mb-4 text-rayhunter-dark-blue hover:text-rayhunter-blue"
|
||||
onclick={() => (showConfig = !showConfig)}
|
||||
>
|
||||
<span>Configuration</span>
|
||||
<svg
|
||||
class="w-6 h-6 transition-transform {showConfig ? 'rotate-180' : ''}"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if showConfig}
|
||||
{#if loading}
|
||||
<div class="text-center py-4">Loading config...</div>
|
||||
{:else if config}
|
||||
<form
|
||||
class="space-y-4"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
saveConfig();
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label for="ui_level" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Device UI Level
|
||||
</label>
|
||||
<select
|
||||
id="ui_level"
|
||||
bind:value={config.ui_level}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
|
||||
>
|
||||
<option value={0}>0 - Invisible mode</option>
|
||||
<option value={1}>1 - Subtle mode (colored line)</option>
|
||||
<option value={2}>2 - Demo mode (orca gif)</option>
|
||||
<option value={3}>3 - EFF logo</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="key_input_mode"
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Device Input Mode
|
||||
</label>
|
||||
<select
|
||||
id="key_input_mode"
|
||||
bind:value={config.key_input_mode}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
|
||||
>
|
||||
<option value={0}>0 - Disable button control</option>
|
||||
<option value={1}
|
||||
>1 - Double-tap power button to start/stop recording</option
|
||||
>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="colorblind_mode"
|
||||
type="checkbox"
|
||||
bind:checked={config.colorblind_mode}
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
|
||||
/>
|
||||
<label for="colorblind_mode" class="ml-2 block text-sm text-gray-700">
|
||||
Colorblind Mode
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t pt-4 mt-6">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">
|
||||
Analyzer Heuristic Settings
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="imsi_requested"
|
||||
type="checkbox"
|
||||
bind:checked={config.analyzers.imsi_requested}
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
|
||||
/>
|
||||
<label for="imsi_requested" class="ml-2 block text-sm text-gray-700">
|
||||
IMSI Requested Heuristic
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="connection_redirect_2g_downgrade"
|
||||
type="checkbox"
|
||||
bind:checked={config.analyzers.connection_redirect_2g_downgrade}
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
|
||||
/>
|
||||
<label
|
||||
for="connection_redirect_2g_downgrade"
|
||||
class="ml-2 block text-sm text-gray-700"
|
||||
>
|
||||
Connection Redirect 2G Downgrade Heuristic
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="lte_sib6_and_7_downgrade"
|
||||
type="checkbox"
|
||||
bind:checked={config.analyzers.lte_sib6_and_7_downgrade}
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
|
||||
/>
|
||||
<label
|
||||
for="lte_sib6_and_7_downgrade"
|
||||
class="ml-2 block text-sm text-gray-700"
|
||||
>
|
||||
LTE SIB6 and SIB7 Downgrade Heuristic
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="null_cipher"
|
||||
type="checkbox"
|
||||
bind:checked={config.analyzers.null_cipher}
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
|
||||
/>
|
||||
<label for="null_cipher" class="ml-2 block text-sm text-gray-700">
|
||||
Null Cipher Heuristic
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div 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="nas_null_cipher" class="ml-2 block text-sm text-gray-700">
|
||||
Incomplete SIB Heuristic
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
class="bg-blue-500 hover:bg-blue-700 disabled:opacity-50 text-white font-bold py-2 px-4 rounded-md flex flex-row gap-1 items-center"
|
||||
>
|
||||
{#if saving}
|
||||
<div
|
||||
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
Saving...
|
||||
{:else}
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
></path>
|
||||
</svg>
|
||||
Apply and restart
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{#if message}
|
||||
<div
|
||||
class="mt-4 p-3 rounded {messageType === 'error'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-green-100 text-green-700'}"
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="text-center py-4 text-red-600">
|
||||
Failed to load configuration. Please try reloading the page.
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
11
daemon/web/src/lib/components/DeleteAllButton.svelte
Normal file
@@ -0,0 +1,11 @@
|
||||
<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`}
|
||||
/>
|
||||
</div>
|
||||
32
daemon/web/src/lib/components/DeleteButton.svelte
Normal file
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
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>
|
||||
27
daemon/web/src/lib/components/DownloadLink.svelte
Normal file
@@ -0,0 +1,27 @@
|
||||
<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>
|
||||
97
daemon/web/src/lib/components/ManifestCard.svelte
Normal file
@@ -0,0 +1,97 @@
|
||||
<script lang="ts">
|
||||
import { ManifestEntry } from '$lib/manifest.svelte';
|
||||
import DownloadLink from '$lib/components/DownloadLink.svelte';
|
||||
import DeleteButton from '$lib/components/DeleteButton.svelte';
|
||||
import AnalysisStatus from './AnalysisStatus.svelte';
|
||||
import AnalysisView from './AnalysisView.svelte';
|
||||
import RecordingControls from './RecordingControls.svelte';
|
||||
let {
|
||||
entry,
|
||||
current,
|
||||
server_is_recording,
|
||||
}: {
|
||||
entry: ManifestEntry;
|
||||
current: boolean;
|
||||
server_is_recording: boolean;
|
||||
} = $props();
|
||||
|
||||
// passing `undefined` as the locale uses the browser default
|
||||
const date_formatter = new Intl.DateTimeFormat(undefined, {
|
||||
timeStyle: 'long',
|
||||
dateStyle: 'short',
|
||||
});
|
||||
let status_row_color = $derived.by(() => {
|
||||
const num_warnings = entry.get_num_warnings();
|
||||
if (num_warnings !== undefined && num_warnings > 0) {
|
||||
return 'bg-red-100';
|
||||
}
|
||||
return current ? 'bg-green-100' : 'bg-gray-100';
|
||||
});
|
||||
let status_border_color = $derived.by(() => {
|
||||
const num_warnings = entry.get_num_warnings();
|
||||
if (num_warnings !== undefined && num_warnings > 0) {
|
||||
return 'border-red-100';
|
||||
}
|
||||
return current ? 'border-green-100' : 'border-gray-100';
|
||||
});
|
||||
let analysis_visible = $state(false);
|
||||
function toggle_analysis_visibility() {
|
||||
analysis_visible = !analysis_visible;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="{status_row_color} {status_border_color} drop-shadow p-4 flex flex-col gap-2 border rounded-md flex-1"
|
||||
>
|
||||
{#if current}
|
||||
<div class="flex flex-row justify-between gap-2">
|
||||
<span class="text-xl mb-2">Current Recording</span>
|
||||
<span class=""
|
||||
><AnalysisStatus
|
||||
onclick={toggle_analysis_visibility}
|
||||
{entry}
|
||||
{analysis_visible}
|
||||
/></span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-row justify-between">
|
||||
<span class="font-bold">ID: {entry.name}</span>
|
||||
{#if !current}
|
||||
<span class=""
|
||||
><AnalysisStatus
|
||||
onclick={toggle_analysis_visibility}
|
||||
{entry}
|
||||
{analysis_visible}
|
||||
/></span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="">{entry.get_readable_qmdl_size()}</span>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="">Start: {date_formatter.format(entry.start_time)}</span>
|
||||
<span class=""
|
||||
>Last Message: {(entry.last_message_time &&
|
||||
date_formatter.format(entry.last_message_time)) ||
|
||||
'N/A'}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-row justify-between lg:justify-end gap-2 mt-2">
|
||||
<DownloadLink url={entry.get_pcap_url()} text="pcap" full_button />
|
||||
<DownloadLink url={entry.get_qmdl_url()} text="qmdl" full_button />
|
||||
<DownloadLink url={entry.get_zip_url()} text="zip" full_button />
|
||||
{#if current}
|
||||
<RecordingControls {server_is_recording} />
|
||||
{:else}
|
||||
<DeleteButton
|
||||
prompt={`Are you sure you want to delete entry ${entry.name}?`}
|
||||
url={entry.get_delete_url()}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="border-b {analysis_visible ? '' : 'hidden'}">
|
||||
<AnalysisView {entry} />
|
||||
</div>
|
||||
</div>
|
||||
38
daemon/web/src/lib/components/ManifestTable.svelte
Normal file
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import { ManifestEntry } from '$lib/manifest.svelte';
|
||||
import TableRow from './ManifestTableRow.svelte';
|
||||
import Card from './ManifestCard.svelte';
|
||||
interface Props {
|
||||
entries: ManifestEntry[];
|
||||
server_is_recording: boolean;
|
||||
}
|
||||
let { entries, server_is_recording }: Props = $props();
|
||||
</script>
|
||||
|
||||
<!--For larger screens we use a table-->
|
||||
<table class="hidden table-auto text-left lg:table">
|
||||
<thead>
|
||||
<tr class="bg-gray-100 drop-shadow">
|
||||
<th class="p-2" scope="col">ID</th>
|
||||
<th class="p-2" scope="col">Started</th>
|
||||
<th class="p-2" scope="col">Last Message</th>
|
||||
<th class="p-2" scope="col">Size</th>
|
||||
<th class="p-2" scope="col">PCAP</th>
|
||||
<th class="p-2" scope="col">QMDL</th>
|
||||
<th class="p-2" scope="col">ZIP</th>
|
||||
<th class="p-2" scope="col">Analysis</th>
|
||||
<th class="p-2" scope="col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each entries as entry, i}
|
||||
<TableRow {entry} current={false} {i} />
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<!--For smaller screens we use cards-->
|
||||
<div class="lg:hidden flex flex-col gap-4">
|
||||
{#each entries as entry}
|
||||
<Card {entry} current={false} {server_is_recording} />
|
||||
{/each}
|
||||
</div>
|
||||
64
daemon/web/src/lib/components/ManifestTableRow.svelte
Normal file
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import { ManifestEntry } from '$lib/manifest.svelte';
|
||||
import DownloadLink from '$lib/components/DownloadLink.svelte';
|
||||
import DeleteButton from '$lib/components/DeleteButton.svelte';
|
||||
import AnalysisStatus from './AnalysisStatus.svelte';
|
||||
import AnalysisView from './AnalysisView.svelte';
|
||||
let {
|
||||
entry,
|
||||
current,
|
||||
i,
|
||||
}: {
|
||||
entry: ManifestEntry;
|
||||
current: boolean;
|
||||
i: number;
|
||||
} = $props();
|
||||
|
||||
// passing `undefined` as the locale uses the browser default
|
||||
const date_formatter = new Intl.DateTimeFormat(undefined, {
|
||||
timeStyle: 'long',
|
||||
dateStyle: 'short',
|
||||
});
|
||||
let alternating_row_color = $derived(i % 2 == 0 ? 'bg-white' : 'bg-gray-100');
|
||||
let status_row_color = $derived.by(() => {
|
||||
const num_warnings = entry.get_num_warnings();
|
||||
if (num_warnings !== undefined && num_warnings > 0) {
|
||||
return 'bg-red-100';
|
||||
}
|
||||
return current ? 'bg-green-100' : alternating_row_color;
|
||||
});
|
||||
let analysis_visible = $state(false);
|
||||
function toggle_analysis_visibility() {
|
||||
analysis_visible = !analysis_visible;
|
||||
}
|
||||
</script>
|
||||
|
||||
<tr class="{status_row_color} drop-shadow">
|
||||
<td class="p-2">{entry.name}</td>
|
||||
<td class="p-2">{date_formatter.format(entry.start_time)}</td>
|
||||
<td class="p-2"
|
||||
>{(entry.last_message_time && date_formatter.format(entry.last_message_time)) || 'N/A'}</td
|
||||
>
|
||||
<td class="p-2">{entry.get_readable_qmdl_size()}</td>
|
||||
<td class="p-2"><DownloadLink url={entry.get_pcap_url()} text="pcap" /></td>
|
||||
<td class="p-2"><DownloadLink url={entry.get_qmdl_url()} text="qmdl" /></td>
|
||||
<td class="p-2"><DownloadLink url={entry.get_zip_url()} text="zip" /></td>
|
||||
<td class="p-2"
|
||||
><AnalysisStatus onclick={toggle_analysis_visibility} {entry} {analysis_visible} /></td
|
||||
>
|
||||
{#if current}
|
||||
<td class="p-2"></td>
|
||||
{:else}
|
||||
<td class="p-2">
|
||||
<DeleteButton
|
||||
prompt={`Are you sure you want to delete entry ${entry.name}?`}
|
||||
url={entry.get_delete_url()}
|
||||
/>
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
<tr class="{alternating_row_color} border-b {analysis_visible ? '' : 'hidden'}">
|
||||
<td class="border-t border-dashed p-2" colspan="9">
|
||||
<AnalysisView {entry} />
|
||||
</td>
|
||||
</tr>
|
||||
100
daemon/web/src/lib/components/RecordingControls.svelte
Normal file
@@ -0,0 +1,100 @@
|
||||
<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';
|
||||
const stop_recording_classes = `${recording_button_classes} bg-red-500 opacity-50 cursor-not-allowed`;
|
||||
const start_recording_classes = `${recording_button_classes} bg-blue-500 opacity-50 cursor-not-allowed`;
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{#if waiting_for_server}
|
||||
<button
|
||||
class={server_is_recording ? stop_recording_classes : start_recording_classes}
|
||||
disabled
|
||||
>
|
||||
<span>{server_is_recording ? 'Stopping...' : 'Starting...'}</span>
|
||||
<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>
|
||||
</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>
|
||||
37
daemon/web/src/lib/components/SystemStatsTable.svelte
Normal file
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { type SystemStats } from '$lib/systemStats';
|
||||
let {
|
||||
stats,
|
||||
}: {
|
||||
stats: SystemStats;
|
||||
} = $props();
|
||||
|
||||
const table_cell_classes = 'border p-1 lg:p-2';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex-1 drop-shadow p-4 flex flex-col gap-2 border rounded-md bg-gray-100 border-gray-100"
|
||||
>
|
||||
<p class="text-xl mb-2">System Information</p>
|
||||
<table class="table-auto border">
|
||||
<tbody>
|
||||
<tr class="border">
|
||||
<th class={table_cell_classes}> Rayhunter Version </th>
|
||||
<td class={table_cell_classes}>{stats.runtime_metadata.rayhunter_version}</td>
|
||||
</tr>
|
||||
<tr class="border">
|
||||
<th class={table_cell_classes}> Storage </th>
|
||||
<td class={table_cell_classes}>
|
||||
{stats.disk_stats.used_percent} used ({stats.disk_stats.used_size} used / {stats
|
||||
.disk_stats.available_size} available)
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b">
|
||||
<th class={table_cell_classes}> Memory (RAM) </th>
|
||||
<td class={table_cell_classes}>
|
||||
Free: {stats.memory_stats.free}, Used: {stats.memory_stats.used}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
1
daemon/web/src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
105
daemon/web/src/lib/manifest.svelte.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { get_report, type AnalysisReport } from './analysis.svelte';
|
||||
import { AnalysisStatus, type AnalysisManager } from './analysisManager.svelte';
|
||||
|
||||
interface JsonManifest {
|
||||
entries: JsonManifestEntry[];
|
||||
current_entry: JsonManifestEntry | null;
|
||||
}
|
||||
|
||||
interface JsonManifestEntry {
|
||||
name: string;
|
||||
start_time: string;
|
||||
last_message_time: string;
|
||||
qmdl_size_bytes: number;
|
||||
}
|
||||
|
||||
export class Manifest {
|
||||
public entries: ManifestEntry[] = [];
|
||||
public current_entry: ManifestEntry | undefined;
|
||||
|
||||
constructor(json: JsonManifest) {
|
||||
for (const entry of json.entries) {
|
||||
this.entries.push(new ManifestEntry(entry));
|
||||
}
|
||||
if (json.current_entry !== null) {
|
||||
this.current_entry = new ManifestEntry(json['current_entry']);
|
||||
}
|
||||
|
||||
// sort entries in reverse chronological order
|
||||
this.entries.reverse();
|
||||
}
|
||||
|
||||
async set_analysis_status(manager: AnalysisManager) {
|
||||
for (const entry of this.entries) {
|
||||
entry.analysis_status = manager.status.get(entry.name);
|
||||
entry.analysis_report = manager.reports.get(entry.name);
|
||||
}
|
||||
|
||||
if (this.current_entry) {
|
||||
try {
|
||||
this.current_entry.analysis_report = await get_report(this.current_entry.name);
|
||||
} catch (err) {
|
||||
this.current_entry.analysis_report = `Err: failed to get analysis report: ${err}`;
|
||||
}
|
||||
|
||||
// the current entry should always be considered "finished", as its
|
||||
// analysis report is always available
|
||||
this.current_entry.analysis_status = AnalysisStatus.Finished;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ManifestEntry {
|
||||
public name = $state('');
|
||||
public start_time: Date;
|
||||
public last_message_time: Date | undefined = $state(undefined);
|
||||
public qmdl_size_bytes = $state(0);
|
||||
public analysis_size_bytes = $state(0);
|
||||
public analysis_status: AnalysisStatus | undefined = $state(undefined);
|
||||
public analysis_report: AnalysisReport | string | undefined = $state(undefined);
|
||||
|
||||
constructor(json: JsonManifestEntry) {
|
||||
this.name = json.name;
|
||||
this.qmdl_size_bytes = json.qmdl_size_bytes;
|
||||
this.start_time = new Date(json.start_time);
|
||||
if (json.last_message_time) {
|
||||
this.last_message_time = new Date(json.last_message_time);
|
||||
}
|
||||
}
|
||||
|
||||
get_readable_qmdl_size(): string {
|
||||
if (this.qmdl_size_bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const dm = 2;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
const i = Math.floor(Math.log(this.qmdl_size_bytes) / Math.log(k));
|
||||
return `${Number.parseFloat((this.qmdl_size_bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
get_num_warnings(): number | undefined {
|
||||
if (this.analysis_report === undefined || typeof this.analysis_report === 'string') {
|
||||
return undefined;
|
||||
}
|
||||
return this.analysis_report.statistics.num_warnings;
|
||||
}
|
||||
|
||||
get_pcap_url(): string {
|
||||
return `/api/pcap/${this.name}.pcapng`;
|
||||
}
|
||||
|
||||
get_qmdl_url(): string {
|
||||
return `/api/qmdl/${this.name}.qmdl`;
|
||||
}
|
||||
|
||||
get_zip_url(): string {
|
||||
return `/api/zip/${this.name}.zip`;
|
||||
}
|
||||
|
||||
get_analysis_report_url(): string {
|
||||
return `/api/analysis-report/${this.name}`;
|
||||
}
|
||||
|
||||
get_delete_url(): string {
|
||||
return `/api/delete-recording/${this.name}`;
|
||||
}
|
||||
}
|
||||
33
daemon/web/src/lib/ndjson.spec.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parse_ndjson } from './ndjson';
|
||||
|
||||
describe('parsing newline-deliminated json', () => {
|
||||
it('parses normal JSON', () => {
|
||||
const json = JSON.stringify({ foo: 100 });
|
||||
const result = parse_ndjson(json);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({ foo: 100 });
|
||||
});
|
||||
|
||||
it('parses simple newline-deliminated json', () => {
|
||||
const json_a = JSON.stringify({ a: 100 });
|
||||
const json_b = JSON.stringify({ b: 200 });
|
||||
const result = parse_ndjson(`${json_a}\n${json_b}`);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual({ a: 100 });
|
||||
expect(result[1]).toEqual({ b: 200 });
|
||||
});
|
||||
|
||||
it('parses newline-deliminated json with escaped newlines within', () => {
|
||||
const json_a = JSON.stringify({ a: 'this one has\n newlines and\nstuff' });
|
||||
const json_b = JSON.stringify({ b: 200 });
|
||||
const result = parse_ndjson(`${json_a}\n${json_b}`);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual({ a: 'this one has\n newlines and\nstuff' });
|
||||
expect(result[1]).toEqual({ b: 200 });
|
||||
});
|
||||
|
||||
it('actually errors out on invalid ndjson', () => {
|
||||
expect(() => parse_ndjson('invalid\njson')).toThrow();
|
||||
});
|
||||
});
|
||||
27
daemon/web/src/lib/ndjson.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export type NewlineDeliminatedJson = any[];
|
||||
|
||||
export function parse_ndjson(input: string): NewlineDeliminatedJson {
|
||||
const lines = input.split('\n');
|
||||
const result = [];
|
||||
let current_line = '';
|
||||
while (lines.length > 0) {
|
||||
current_line += lines.shift();
|
||||
if (current_line.length === 0) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const entry = JSON.parse(current_line);
|
||||
result.push(entry);
|
||||
current_line = '';
|
||||
} catch (e) {
|
||||
// if this chunk wasn't valid JSON, assume there was an escaped
|
||||
// newline in the JSON line, so simply continue to the next one.
|
||||
// however, if we've reached the end of the input, that means we
|
||||
// were given invalid nd-json
|
||||
if (lines.length === 0) {
|
||||
throw new Error(`unable to parse invalid nd-json: ${e}, "${current_line}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
26
daemon/web/src/lib/systemStats.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export interface SystemStats {
|
||||
disk_stats: DiskStats;
|
||||
memory_stats: MemoryStats;
|
||||
runtime_metadata: RuntimeMetadata;
|
||||
}
|
||||
|
||||
export interface RuntimeMetadata {
|
||||
rayhunter_version: string;
|
||||
system_os: string;
|
||||
arch: string;
|
||||
}
|
||||
|
||||
export interface DiskStats {
|
||||
partition: string;
|
||||
total_size: string;
|
||||
used_size: string;
|
||||
available_size: string;
|
||||
used_percent: string;
|
||||
mounted_on: string;
|
||||
}
|
||||
|
||||
export interface MemoryStats {
|
||||
total: string;
|
||||
used: string;
|
||||
free: string;
|
||||
}
|
||||
58
daemon/web/src/lib/utils.svelte.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Manifest } from './manifest.svelte';
|
||||
import type { SystemStats } from './systemStats';
|
||||
|
||||
export interface AnalyzerConfig {
|
||||
imsi_requested: boolean;
|
||||
connection_redirect_2g_downgrade: boolean;
|
||||
lte_sib6_and_7_downgrade: boolean;
|
||||
null_cipher: boolean;
|
||||
nas_null_cipher: boolean;
|
||||
incomplete_sib: boolean;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
ui_level: number;
|
||||
colorblind_mode: boolean;
|
||||
key_input_mode: number;
|
||||
analyzers: AnalyzerConfig;
|
||||
}
|
||||
|
||||
export async function req(method: string, url: string): Promise<string> {
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
});
|
||||
const body = await response.text();
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
return body;
|
||||
} else {
|
||||
throw new Error(body);
|
||||
}
|
||||
}
|
||||
|
||||
export async function get_manifest(): Promise<Manifest> {
|
||||
const manifest_json = JSON.parse(await req('GET', '/api/qmdl-manifest'));
|
||||
return new Manifest(manifest_json);
|
||||
}
|
||||
|
||||
export async function get_system_stats(): Promise<SystemStats> {
|
||||
return JSON.parse(await req('GET', '/api/system-stats'));
|
||||
}
|
||||
|
||||
export async function get_config(): Promise<Config> {
|
||||
return JSON.parse(await req('GET', '/api/config'));
|
||||
}
|
||||
|
||||
export async function set_config(config: Config): Promise<void> {
|
||||
const response = await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(error);
|
||||
}
|
||||
}
|
||||
1
daemon/web/src/routes/+layout.js
Normal file
@@ -0,0 +1 @@
|
||||
export const prerender = true;
|
||||
6
daemon/web/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,6 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
138
daemon/web/src/routes/+page.svelte
Normal file
@@ -0,0 +1,138 @@
|
||||
<script lang="ts">
|
||||
import { ManifestEntry } from '$lib/manifest.svelte';
|
||||
import { get_manifest, get_system_stats } from '$lib/utils.svelte';
|
||||
import ManifestTable from '$lib/components/ManifestTable.svelte';
|
||||
import Card from '$lib/components/ManifestCard.svelte';
|
||||
import type { SystemStats } from '$lib/systemStats';
|
||||
import { AnalysisManager } from '$lib/analysisManager.svelte';
|
||||
import SystemStatsTable from '$lib/components/SystemStatsTable.svelte';
|
||||
import DeleteAllButton from '$lib/components/DeleteAllButton.svelte';
|
||||
import RecordingControls from '$lib/components//RecordingControls.svelte';
|
||||
import ConfigForm from '$lib/components/ConfigForm.svelte';
|
||||
|
||||
let manager: AnalysisManager = new AnalysisManager();
|
||||
let loaded = $state(false);
|
||||
let 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;
|
||||
|
||||
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">
|
||||
<!-- https://www.w3.org/WAI/tutorials/images/decorative/ -->
|
||||
<img src="/rayhunter_text.png" alt="" 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 current_entry}
|
||||
<Card entry={current_entry} current={true} server_is_recording={!!current_entry} />
|
||||
{: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 server_is_recording={!!current_entry} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<SystemStatsTable stats={system_stats!} />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-xl">History</span>
|
||||
<ManifestTable {entries} server_is_recording={!!current_entry} />
|
||||
</div>
|
||||
<DeleteAllButton />
|
||||
<ConfigForm />
|
||||
{:else}
|
||||
<div class="flex flex-col justify-center items-center">
|
||||
<!-- https://www.w3.org/WAI/tutorials/images/decorative/ -->
|
||||
<img src="/rayhunter_orca_only.png" alt="" class="h-48 animate-spin" />
|
||||
<p class="text-xl">Loading...</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
BIN
daemon/web/static/favicon.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
daemon/web/static/rayhunter_icon.png
Normal file
|
After Width: | Height: | Size: 218 KiB |
BIN
daemon/web/static/rayhunter_orca_only.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
daemon/web/static/rayhunter_text.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
26
daemon/web/svelte.config.js
Normal file
@@ -0,0 +1,26 @@
|
||||
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,
|
||||
}),
|
||||
output: {
|
||||
// Force everything into one HTML file. SvelteKit will still generate
|
||||
// a lot of JS files but they are deadweight and will not be included
|
||||
// in the rust binary.
|
||||
bundleStrategy: 'inline',
|
||||
},
|
||||
version: {
|
||||
// Use a deterministic version string for reproducible builds.
|
||||
// Without this option, SvelteKit will use a timestamp.
|
||||
name: process.env.GITHUB_SHA || 'dev',
|
||||
},
|
||||
},
|
||||
};
|
||||
17
daemon/web/tailwind.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
export default {
|
||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'rayhunter-blue': '#4e4eb1',
|
||||
'rayhunter-dark-blue': '#3f3da0',
|
||||
'rayhunter-green': '#94ea18',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
plugins: [],
|
||||
} as Config;
|
||||
19
daemon/web/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||
}
|
||||
39
daemon/web/vite.config.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
configure: (proxy, _options) => {
|
||||
proxy.on('error', (err, _req, _res) => {
|
||||
console.log('proxy err:', err);
|
||||
});
|
||||
proxy.on('proxyReq', (proxyReq, req, _res) => {
|
||||
console.log('Sending Request to the Target:', req.method, req.url);
|
||||
});
|
||||
proxy.on('proxyRes', (proxyRes, req, _res) => {
|
||||
console.log(
|
||||
'Received Response from the Target:',
|
||||
proxyRes.statusCode,
|
||||
req.url
|
||||
);
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [sveltekit()],
|
||||
build: {
|
||||
// Force everything into one HTML file. SvelteKit will still generate
|
||||
// a lot of JS files but they are deadweight and will not be included
|
||||
// in the rust binary.
|
||||
assetsInlineLimit: Infinity,
|
||||
},
|
||||
test: {
|
||||
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||
},
|
||||
});
|
||||
10
dist/config.toml.example
vendored
@@ -1,10 +0,0 @@
|
||||
# cat config.toml
|
||||
qmdl_store_path = "/data/rayhunter/qmdl"
|
||||
port = 8080
|
||||
readonly_mode = false
|
||||
# UI Levels:
|
||||
# 0 = invisible mode, no indicator that rayhunter is running
|
||||
# 1 = Subtle mode, display a green line at the top of the screen when rayhunter is running
|
||||
# 2 = Demo Mode, display a fun orca gif
|
||||
# 3 = display the EFF logo
|
||||
ui_level = 1
|
||||
34
dist/config.toml.in
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
# cat config.toml
|
||||
qmdl_store_path = "/data/rayhunter/qmdl"
|
||||
port = 8080
|
||||
debug_mode = false
|
||||
colorblind_mode = false
|
||||
# Device selection. This will be overwritten by the installer. Defaults to "orbic".
|
||||
#device = "orbic"
|
||||
# UI Levels:
|
||||
#
|
||||
# Orbic and TP-Link with color display:
|
||||
# 0 = invisible mode, no indicator that rayhunter is running
|
||||
# 1 = Subtle mode, display a colored line at the top of the screen when rayhunter is running (green=running, white=paused, red=warnings)
|
||||
# 2 = Demo Mode, display a fun orca gif
|
||||
# 3 = display the EFF logo
|
||||
#
|
||||
# TP-Link with one-bit display:
|
||||
# 0 = invisible mode
|
||||
# 1..3 = show emoji for status. :) for running, ! for warnings, no mouth for paused.
|
||||
ui_level = 1
|
||||
|
||||
# 0 = rayhunter does not read button presses
|
||||
# 1 = double-tapping the power button starts/stops recordings
|
||||
key_input_mode = 0
|
||||
|
||||
# Analyzer Configuration
|
||||
# Enable/disable specific IMSI catcher detection heuristics
|
||||
# See https://github.com/EFForg/rayhunter/blob/main/doc/heuristics.md for details
|
||||
[analyzers]
|
||||
imsi_requested = true
|
||||
connection_redirect_2g_downgrade = true
|
||||
lte_sib6_and_7_downgrade = true
|
||||
null_cipher = true
|
||||
nas_null_cipher = true
|
||||
incomplete_sib = true
|
||||
96
dist/install-common.sh
vendored
@@ -1,96 +0,0 @@
|
||||
#!/bin/env bash
|
||||
install() {
|
||||
if [[ -z "${SERIAL_PATH}" ]]; then
|
||||
echo "SERIAL_PATH not set, did you run this from install-linux.sh or install-mac.sh?"
|
||||
exit 1
|
||||
fi
|
||||
check_adb
|
||||
force_debug_mode
|
||||
setup_rootshell
|
||||
setup_rayhunter
|
||||
test_rayhunter
|
||||
}
|
||||
|
||||
check_adb() {
|
||||
if ! command -v adb &> /dev/null
|
||||
then
|
||||
echo "adb not found, please ensure it's installed or check the README.md"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
force_debug_mode() {
|
||||
echo " Force a switch into the debug mode to enable ADB"
|
||||
"$SERIAL_PATH" --root
|
||||
echo -n "adb enabled, waiting for reboot"
|
||||
wait_for_adb_shell
|
||||
echo "it's alive!"
|
||||
}
|
||||
|
||||
wait_for_adb_shell() {
|
||||
until adb shell true 2> /dev/null
|
||||
do
|
||||
echo -n .
|
||||
sleep 1
|
||||
done
|
||||
echo
|
||||
}
|
||||
|
||||
setup_rootshell() {
|
||||
_adb_push rootshell /tmp/
|
||||
"$SERIAL_PATH" "AT+SYSCMD=cp /tmp/rootshell /bin/rootshell"
|
||||
sleep 1
|
||||
"$SERIAL_PATH" "AT+SYSCMD=chown root /bin/rootshell"
|
||||
sleep 1
|
||||
"$SERIAL_PATH" "AT+SYSCMD=chmod 4755 /bin/rootshell"
|
||||
echo "we have root!"
|
||||
adb shell /bin/rootshell -c id
|
||||
}
|
||||
|
||||
_adb_push() {
|
||||
adb push "$(dirname "$0")/$1" "$2"
|
||||
}
|
||||
|
||||
setup_rayhunter() {
|
||||
adb shell '/bin/rootshell -c "mkdir -p /data/rayhunter"'
|
||||
_adb_push config.toml.example /data/rayhunter/config.toml
|
||||
_adb_push rayhunter-daemon /data/rayhunter/
|
||||
_adb_push scripts/rayhunter_daemon /tmp/rayhunter_daemon
|
||||
_adb_push scripts/misc-daemon /tmp/misc-daemon
|
||||
adb shell '/bin/rootshell -c "cp /tmp/rayhunter_daemon /etc/init.d/rayhunter_daemon"'
|
||||
adb shell '/bin/rootshell -c "cp /tmp/misc-daemon /etc/init.d/misc-daemon"'
|
||||
adb shell '/bin/rootshell -c "chmod 755 /etc/init.d/rayhunter_daemon"'
|
||||
adb shell '/bin/rootshell -c "chmod 755 /etc/init.d/misc-daemon"'
|
||||
echo -n "rebooting, this may take a sec..."
|
||||
adb shell '/bin/rootshell -c reboot'
|
||||
|
||||
# first wait for shutdown (it can take ~10s)
|
||||
until ! adb shell true 2> /dev/null
|
||||
do
|
||||
echo -n '.'
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# now wait for boot to finish
|
||||
wait_for_adb_shell
|
||||
|
||||
echo "rebooted successfully!"
|
||||
}
|
||||
|
||||
test_rayhunter() {
|
||||
URL="http://localhost:8080"
|
||||
adb forward tcp:8080 tcp:8080
|
||||
echo -n "checking for rayhunter server..."
|
||||
|
||||
SECONDS=0
|
||||
while (( SECONDS < 30 )); do
|
||||
if curl -L --fail-with-body "$URL" -o /dev/null -s; then
|
||||
echo
|
||||
echo "success! you can access rayhunter at $URL"
|
||||
return
|
||||
fi
|
||||
sleep 1
|
||||
echo -n "."
|
||||
done
|
||||
echo "timeout reached! failed to reach rayhunter url $URL, something went wrong :("
|
||||
}
|
||||
6
dist/install-linux.sh
vendored
@@ -1,6 +0,0 @@
|
||||
#!/bin/env bash
|
||||
|
||||
set -e
|
||||
export SERIAL_PATH="./serial-ubuntu-latest/serial"
|
||||
. "$(dirname "$0")"/install-common.sh
|
||||
install
|
||||
6
dist/install-mac.sh
vendored
@@ -1,6 +0,0 @@
|
||||
#!/bin/env bash
|
||||
|
||||
set -e
|
||||
export SERIAL_PATH="./serial-mac-latest/serial"
|
||||
. "$(dirname "$0")"/install-common.sh
|
||||
install
|
||||