mirror of
https://github.com/EFForg/rayhunter.git
synced 2026-05-30 00:59:27 -07:00
Compare commits
883 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58c60c2661 | ||
|
|
e0ae8a0298 | ||
|
|
e6a3a4331e | ||
|
|
9191540e86 | ||
|
|
0a93e93838 | ||
|
|
0580a8af33 | ||
|
|
a80a985b40 | ||
|
|
228596ef30 | ||
|
|
a7409b281b | ||
|
|
6a57bdebc4 | ||
|
|
7cb405c465 | ||
|
|
bada3846dc | ||
|
|
f0849340cf | ||
|
|
512cf784a7 | ||
|
|
100960bbe1 | ||
|
|
9d275e1793 | ||
|
|
fd190c4b75 | ||
|
|
ff838c41fa | ||
|
|
a031e8ccfc | ||
|
|
a6f5faa80e | ||
|
|
43f1dfce64 | ||
|
|
54adaf913d | ||
|
|
ab418ecc84 | ||
|
|
2fd028dc78 | ||
|
|
d413840c08 | ||
|
|
2f1b583e00 | ||
|
|
adeeb75166 | ||
|
|
4ca23f37c3 | ||
|
|
15b80ecdd5 | ||
|
|
c5de9b045a | ||
|
|
37283deddb | ||
|
|
49d7bbca34 | ||
|
|
a4c32f49ae | ||
|
|
ec30a9557c | ||
|
|
a7d38730f5 | ||
|
|
d9facdf6cb | ||
|
|
90f49f73c8 | ||
|
|
8aa45f4b53 | ||
|
|
d8da6118da | ||
|
|
3e38f500a9 | ||
|
|
83664e23f2 | ||
|
|
44c7f31fec | ||
|
|
301107be6c | ||
|
|
7b97ffc01d | ||
|
|
b72712faa2 | ||
|
|
05fdc0eee2 | ||
|
|
8fb27b08f9 | ||
|
|
062db87572 | ||
|
|
9b6c4cee0b | ||
|
|
9d50db40b9 | ||
|
|
d41c4bba3e | ||
|
|
1d5ed54033 | ||
|
|
24e79aad9d | ||
|
|
bc7dcc97c6 | ||
|
|
480b6f8681 | ||
|
|
0c624c2bc2 | ||
|
|
ec6967e2a1 | ||
|
|
912f7dfeaa | ||
|
|
51f1a33e86 | ||
|
|
87c79bddf7 | ||
|
|
5efa12f358 | ||
|
|
e77fe469da | ||
|
|
ed8b1903f8 | ||
|
|
89d1d71ec9 | ||
|
|
9be35de90e | ||
|
|
8f9be746d3 | ||
|
|
1347e3107a | ||
|
|
715efc4b0d | ||
|
|
836ec2169d | ||
|
|
9128eefcfc | ||
|
|
4f3c7fb7a9 | ||
|
|
2d3824072d | ||
|
|
ed2781a4be | ||
|
|
ffcf683ae5 | ||
|
|
49fd777c83 | ||
|
|
84a3155a1f | ||
|
|
184f4bd7a2 | ||
|
|
f7759721e3 | ||
|
|
744d0772c2 | ||
|
|
2cd49b3757 | ||
|
|
e44230c043 | ||
|
|
e27da68b5d | ||
|
|
2a68c99897 | ||
|
|
987d95c23e | ||
|
|
9ef6b43dac | ||
|
|
ffc42f6ffd | ||
|
|
2781b3c7ed | ||
|
|
fd63210bf9 | ||
|
|
a271c4ddf4 | ||
|
|
bef6b51e28 | ||
|
|
781d07230c | ||
|
|
1f171521e4 | ||
|
|
5b2cf3cec4 | ||
|
|
62e8d4c40f | ||
|
|
72c19e0f04 | ||
|
|
9b52f46c1a | ||
|
|
51d4e86b3a | ||
|
|
33fafd4707 | ||
|
|
6e4cbac4b1 | ||
|
|
b453c92d6a | ||
|
|
733c8b227d | ||
|
|
b43217ef35 | ||
|
|
40a0dec361 | ||
|
|
b2d5ed356f | ||
|
|
6033757ddb | ||
|
|
6b4f98183e | ||
|
|
bd2329d6cc | ||
|
|
d1311e0ba3 | ||
|
|
75cf03d638 | ||
|
|
be15035ad4 | ||
|
|
a3d0d8f4f9 | ||
|
|
2c30218743 | ||
|
|
eb65214989 | ||
|
|
8d86aeb591 | ||
|
|
23cef7349e | ||
|
|
07e0115192 | ||
|
|
82b53c6187 | ||
|
|
883175aa59 | ||
|
|
bd52718ea7 | ||
|
|
d607c63cc8 | ||
|
|
9e08e662ff | ||
|
|
08920e02b8 | ||
|
|
7e2df91702 | ||
|
|
262f583355 | ||
|
|
9ae1563286 | ||
|
|
2bd6efa503 | ||
|
|
e06769158b | ||
|
|
b341ef2d1e | ||
|
|
3a807f48b2 | ||
|
|
bc3f0bf515 | ||
|
|
d3290a2c2d | ||
|
|
579c2c1f3f | ||
|
|
7977a01a88 | ||
|
|
78dd2f74a4 | ||
|
|
dd70a2a15d | ||
|
|
81a193959c | ||
|
|
7209910c11 | ||
|
|
3615cbf2dd | ||
|
|
61793179e5 | ||
|
|
cdc7a46162 | ||
|
|
ffe58ab72b | ||
|
|
7906bf7d67 | ||
|
|
5e4174c9f3 | ||
|
|
2a8fee25f9 | ||
|
|
516e878661 | ||
|
|
5fbc540fa0 | ||
|
|
676cd3c862 | ||
|
|
a8cb363112 | ||
|
|
6172236a3c | ||
|
|
485d1a99f6 | ||
|
|
f6e118a5cc | ||
|
|
4cdc9961d3 | ||
|
|
c18579583c | ||
|
|
565b6d188d | ||
|
|
80f12ffaaa | ||
|
|
3e9af006e1 | ||
|
|
73a5d324c4 | ||
|
|
bb6135c682 | ||
|
|
3b44234ae1 | ||
|
|
9e9fe4d392 | ||
|
|
2c92315125 | ||
|
|
7bc55bf432 | ||
|
|
2a7c5b4365 | ||
|
|
d48d5755c6 | ||
|
|
1cf1d6d5b9 | ||
|
|
c8d1b52ca7 | ||
|
|
04efe7bb75 | ||
|
|
3f3b6168b3 | ||
|
|
992a28af57 | ||
|
|
39c8844967 | ||
|
|
ef006d83a6 | ||
|
|
bc9022530a | ||
|
|
af2445cc38 | ||
|
|
e33f143830 | ||
|
|
f5360b042c | ||
|
|
a16fb9b678 | ||
|
|
3349895a3e | ||
|
|
30b517069a | ||
|
|
4efc2d5db3 | ||
|
|
5e066682b3 | ||
|
|
01aefe25c9 | ||
|
|
e8e9f9366c | ||
|
|
fa346989e6 | ||
|
|
d942545ac3 | ||
|
|
e162070a04 | ||
|
|
2e42750b09 | ||
|
|
e375e4587a | ||
|
|
2a30e2d709 | ||
|
|
fe2b8b3456 | ||
|
|
cedfe2d4d7 | ||
|
|
22be337f62 | ||
|
|
6326c5e783 | ||
|
|
ea5aa6cee2 | ||
|
|
65d4f22e09 | ||
|
|
450434b4f9 | ||
|
|
4e93e03e6a | ||
|
|
e416d6e311 | ||
|
|
0eebe890c1 | ||
|
|
28c9f44f73 | ||
|
|
85fa73ddd6 | ||
|
|
b8b90268b9 | ||
|
|
9e5de4a445 | ||
|
|
643fb802be | ||
|
|
93f22172cc | ||
|
|
d5f2dd9813 | ||
|
|
d413a76b30 | ||
|
|
fc532682df | ||
|
|
8569a88f86 | ||
|
|
e60035f744 | ||
|
|
1a80a0576c | ||
|
|
fa5c2bf5d1 | ||
|
|
ce8cbb743f | ||
|
|
13c1602f76 | ||
|
|
e2cde3be90 | ||
|
|
8ed3459349 | ||
|
|
5ccdcc8685 | ||
|
|
dac838eea9 | ||
|
|
9d33c161b6 | ||
|
|
f6ff61f26b | ||
|
|
9f57edd385 | ||
|
|
69260d21ac | ||
|
|
f65e5708fc | ||
|
|
6eba455e42 | ||
|
|
dd0b8050b8 | ||
|
|
6009123649 | ||
|
|
549d3a6a8f | ||
|
|
3dc807fc63 | ||
|
|
95fe938eeb | ||
|
|
3ada0fa259 | ||
|
|
48a4b43a39 | ||
|
|
f3c34ce0d3 | ||
|
|
1b5575e5a6 | ||
|
|
1cf6f5d339 | ||
|
|
b00f17d8fc | ||
|
|
766f3461d3 | ||
|
|
d30dd6fd9d | ||
|
|
10e76e351e | ||
|
|
301d130cdd | ||
|
|
7a602b577d | ||
|
|
f52c673b25 | ||
|
|
e6b9624a34 | ||
|
|
15c0ba3805 | ||
|
|
de4a622c68 | ||
|
|
a582715177 | ||
|
|
e68ba6ba52 | ||
|
|
e216043a14 | ||
|
|
e2bc3a0a67 | ||
|
|
87d6d1691a | ||
|
|
7475cd5cd9 | ||
|
|
cef94ba6b0 | ||
|
|
d7c973ea95 | ||
|
|
64d657efd6 | ||
|
|
16447ed8bf | ||
|
|
663d0abb57 | ||
|
|
f49d11f034 | ||
|
|
56dcfdb47c | ||
|
|
a46ede37b6 | ||
|
|
69dc528f34 | ||
|
|
29ce6729ee | ||
|
|
5919a19aba | ||
|
|
35ca590e46 | ||
|
|
56122f6559 | ||
|
|
bbab29ae0b | ||
|
|
2a620fd1fb | ||
|
|
515bb40a76 | ||
|
|
a5ec1c9505 | ||
|
|
806bd62a0e | ||
|
|
6ceced2d31 | ||
|
|
856374c05a | ||
|
|
983867c2a6 | ||
|
|
145d0a295a | ||
|
|
c021b9150d | ||
|
|
ce916dcd10 | ||
|
|
898bdbb6cd | ||
|
|
375789aad9 | ||
|
|
85f7b2cc81 | ||
|
|
781d11ed72 | ||
|
|
6927da49b4 | ||
|
|
479505f738 | ||
|
|
468b07faf0 | ||
|
|
493fdfa227 | ||
|
|
ffdad4aed8 | ||
|
|
33e4fbc544 | ||
|
|
8c510b43c9 | ||
|
|
46850e2739 | ||
|
|
53e3b8ee34 | ||
|
|
0fc51d79f4 | ||
|
|
ad4e971e77 | ||
|
|
c5a79e545d | ||
|
|
9d92ab3c01 | ||
|
|
cf254b66ff | ||
|
|
cddc590c77 | ||
|
|
9d736f5bf0 | ||
|
|
e5df43d7f5 | ||
|
|
a8667cc3a0 | ||
|
|
3239daa011 | ||
|
|
651511cc63 | ||
|
|
211066ec7b | ||
|
|
16ec9e28df | ||
|
|
4462f02c10 | ||
|
|
5bd2d9a58e | ||
|
|
603d65a3bd | ||
|
|
c0a9cf62df | ||
|
|
0a20e659be | ||
|
|
ce599dc432 | ||
|
|
85b50bc301 | ||
|
|
5249714717 | ||
|
|
67974264f9 | ||
|
|
f562d33be3 | ||
|
|
0531aa0e3a | ||
|
|
dd78f5007d | ||
|
|
1c08708bc4 | ||
|
|
0f53da58bc | ||
|
|
01010df4ec | ||
|
|
481f02f81f | ||
|
|
8c67a92b07 | ||
|
|
31bd60dea1 | ||
|
|
13877f7209 | ||
|
|
f4522dbe3d | ||
|
|
30bb18016e | ||
|
|
c6aa53acd2 | ||
|
|
c6882ed173 | ||
|
|
5c03f6ea03 | ||
|
|
5184c6138d | ||
|
|
c893f8e2a9 | ||
|
|
2e6343c343 | ||
|
|
da4a86be13 | ||
|
|
55794cbdd5 | ||
|
|
e36b490d15 | ||
|
|
574e897610 | ||
|
|
1f19bc880f | ||
|
|
8dc6206683 | ||
|
|
7184ccd5c1 | ||
|
|
cb22e179d6 | ||
|
|
a3db5029ad | ||
|
|
9f661ab398 | ||
|
|
412ad3d8bf | ||
|
|
4d2d49326a | ||
|
|
c26ad29ffb | ||
|
|
f57fc611c2 | ||
|
|
38a408757a | ||
|
|
0540504eea | ||
|
|
28a0c06017 | ||
|
|
6141087f9d | ||
|
|
7a053a4f89 | ||
|
|
6473c05e3e | ||
|
|
c697773244 | ||
|
|
fe6afac817 | ||
|
|
8e708f145e | ||
|
|
03c00a1f19 | ||
|
|
64842c7140 | ||
|
|
e108c21fc2 | ||
|
|
49a2108214 | ||
|
|
53a6cbe95a | ||
|
|
398997af67 | ||
|
|
6b109a9d76 | ||
|
|
d9688b1796 | ||
|
|
7466c1c669 | ||
|
|
6a51050921 | ||
|
|
0935cf8239 | ||
|
|
d25e9588e2 | ||
|
|
a8ff95a07b | ||
|
|
ac86277903 | ||
|
|
8e9abc718a | ||
|
|
d92fb16c57 | ||
|
|
f8824ce7e7 | ||
|
|
9694aa826b | ||
|
|
b859dde0c8 | ||
|
|
5b6a73bc44 | ||
|
|
8cbdbf5ebe | ||
|
|
ccce63e90c | ||
|
|
68b13ea09e | ||
|
|
672d825bdb | ||
|
|
fd216ecb72 | ||
|
|
07d43b5924 | ||
|
|
bd3e439a1d | ||
|
|
5491c3f3a0 | ||
|
|
fa14e4ecfc | ||
|
|
8583064e46 | ||
|
|
d3bd8d9dfc | ||
|
|
b16a351727 | ||
|
|
cd781fe8d8 | ||
|
|
df00e00076 | ||
|
|
1a810cfb33 | ||
|
|
b16b1af65e | ||
|
|
a346449ec5 | ||
|
|
464740a1a7 | ||
|
|
e07b0b05e7 | ||
|
|
578bc0d234 | ||
|
|
751d504440 | ||
|
|
29c944af45 | ||
|
|
e239653a44 | ||
|
|
841bc7b015 | ||
|
|
22d927aa25 | ||
|
|
5b59efa4c8 | ||
|
|
f273d28728 | ||
|
|
f1e283b52c | ||
|
|
1011c4b123 | ||
|
|
5db24e4b21 | ||
|
|
a72e4b2234 | ||
|
|
ca0151f656 | ||
|
|
56930db130 | ||
|
|
f018b8f662 | ||
|
|
7e0f12f1c5 | ||
|
|
e32a6f5b2e | ||
|
|
58618f3412 | ||
|
|
003a8b280b | ||
|
|
27bf20fbf4 | ||
|
|
b7636386fc | ||
|
|
f23cc07652 | ||
|
|
f9b621bde9 | ||
|
|
a4cb9454bd | ||
|
|
fbac464b46 | ||
|
|
b923d9d5a6 | ||
|
|
790c0963cd | ||
|
|
32106ac0f4 | ||
|
|
1ce4d99c59 | ||
|
|
b055ddc670 | ||
|
|
09d4328dc2 | ||
|
|
1a4deb7524 | ||
|
|
0585e0f996 | ||
|
|
c783831e78 | ||
|
|
3ddbaa07ca | ||
|
|
83f246e9af | ||
|
|
0d96b4c103 | ||
|
|
7cd8835cab | ||
|
|
e81df18315 | ||
|
|
0915103ede | ||
|
|
da18a1f9da | ||
|
|
5bb3dc9db5 | ||
|
|
c2c6004f4e | ||
|
|
e320874854 | ||
|
|
300215206c | ||
|
|
5e328b889b | ||
|
|
97cbe62f42 | ||
|
|
27408dd64a | ||
|
|
e5c0e13d32 | ||
|
|
41133ba793 | ||
|
|
0be2b02349 | ||
|
|
81eb3eac57 | ||
|
|
3247d35b7e | ||
|
|
355242fa71 | ||
|
|
72d6c65f29 | ||
|
|
5e66c26e70 | ||
|
|
b0d8307a14 | ||
|
|
cf0875f2e3 | ||
|
|
1c51e5ed6f | ||
|
|
3a393fc29f | ||
|
|
b97421d220 | ||
|
|
1bf386d5b7 | ||
|
|
8de4dcfd18 | ||
|
|
c0b1d4608a | ||
|
|
ee8bf0107a | ||
|
|
664ffc8c75 | ||
|
|
d03debe67c | ||
|
|
60922afc87 | ||
|
|
932fef32b9 | ||
|
|
e259417f35 | ||
|
|
3889c89b5a | ||
|
|
bd074066c5 | ||
|
|
8b44f604ea | ||
|
|
ef7b8129ef | ||
|
|
c3fd724ac1 | ||
|
|
28ead37111 | ||
|
|
6efe83b36d | ||
|
|
4d0427fe68 | ||
|
|
1ee35dad71 | ||
|
|
5d2a5a2577 | ||
|
|
a4f4e12a57 | ||
|
|
55178e60fd | ||
|
|
5019f2a9d1 | ||
|
|
f55d9128d4 | ||
|
|
25978a4da4 | ||
|
|
4ad79707bb | ||
|
|
5f45ae31d8 | ||
|
|
ed3072eb8e | ||
|
|
94289dcad5 | ||
|
|
84534bbb2c | ||
|
|
1d50440c85 | ||
|
|
2c05f3d94e | ||
|
|
2b86691e57 | ||
|
|
0a15ca1b1a | ||
|
|
eeef42f4cb | ||
|
|
04cf0ab73a | ||
|
|
23a0f72c2f | ||
|
|
efae6203a9 | ||
|
|
2e4de4a2df | ||
|
|
deeab1f1b0 | ||
|
|
83dba77cba | ||
|
|
542aff4fdf | ||
|
|
aac0c34eaa | ||
|
|
2ececf9c58 | ||
|
|
2cba26a4cc | ||
|
|
48c0592b18 | ||
|
|
a21c9af354 | ||
|
|
0c241aba23 | ||
|
|
b2502847a1 | ||
|
|
be6f29dcf1 | ||
|
|
2114206909 | ||
|
|
f735f033d3 | ||
|
|
b825174a07 | ||
|
|
29823d3e82 | ||
|
|
e52d382514 | ||
|
|
a17e255148 | ||
|
|
0f98b05475 | ||
|
|
5e5514a11f | ||
|
|
9904b74d21 | ||
|
|
d166dfc13d | ||
|
|
9b759e6b42 | ||
|
|
5614c725a0 | ||
|
|
5a7fc2a063 | ||
|
|
e601320b3f | ||
|
|
0b05d1617c | ||
|
|
e7ba02173a | ||
|
|
0b0dd4ed43 | ||
|
|
f2ff1be2ec | ||
|
|
9f9adea5a1 | ||
|
|
cb2092d14f | ||
|
|
76cdb3ecf1 | ||
|
|
bee5152381 | ||
|
|
2634271715 | ||
|
|
58913314aa | ||
|
|
4f5bf4aa78 | ||
|
|
bfc85c5103 | ||
|
|
7923327ba9 | ||
|
|
3ff714972c | ||
|
|
a5d8e601d9 | ||
|
|
5272a99fb5 | ||
|
|
5d61ad53b4 | ||
|
|
88ee4fc87e | ||
|
|
8aadfc20f2 | ||
|
|
a234df1e1e | ||
|
|
de25008742 | ||
|
|
185da9cb36 | ||
|
|
c366eb9e4d | ||
|
|
5dfbeaef64 | ||
|
|
62e4c15eb5 | ||
|
|
02c98a8e8e | ||
|
|
6c02f56250 | ||
|
|
f56acdf89d | ||
|
|
cb6f79f67a | ||
|
|
95951c5c38 | ||
|
|
241fb2789b | ||
|
|
3a3adb055b | ||
|
|
3ae2636d9e | ||
|
|
79b2628d2f | ||
|
|
cba898daf6 | ||
|
|
cb1df974e4 | ||
|
|
86e08f9a85 | ||
|
|
fb2149f0c8 | ||
|
|
bf2b00ce47 | ||
|
|
fd453900c2 | ||
|
|
3d29c5f306 | ||
|
|
74623dea02 | ||
|
|
48e73a0a41 | ||
|
|
a36863e002 | ||
|
|
48aac0f0bb | ||
|
|
5749c305c6 | ||
|
|
f53688086d | ||
|
|
bd2e0b4394 | ||
|
|
1eea086199 | ||
|
|
d36c1f10cd | ||
|
|
8d8d2bd8ec | ||
|
|
f2b722ad5f | ||
|
|
5e2058e7ac | ||
|
|
60daf4b716 | ||
|
|
4df317b028 | ||
|
|
d7fb8b9c85 | ||
|
|
d399532494 | ||
|
|
45df91a364 | ||
|
|
672ed8c6c6 | ||
|
|
5c7c7cd766 | ||
|
|
f41a8d38fe | ||
|
|
f9c8c4671e | ||
|
|
723b20541e | ||
|
|
272a4aeabf | ||
|
|
6ae70556ba | ||
|
|
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 |
11
.cargo/audit.toml
Normal file
11
.cargo/audit.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[advisories]
|
||||
ignore = [
|
||||
# RSA Marvin Attack in `rsa`, dragged in through rustcrypto (dev builds)
|
||||
# and adb_client (USB signing only, unrelated to marvin attack which
|
||||
# targets decryption).
|
||||
"RUSTSEC-2023-0071",
|
||||
# paste crate being unmaintained is not important. it's not dealing with
|
||||
# user-input. we could get rid of this warning by disabling the image
|
||||
# dependency in adb-client.
|
||||
"RUSTSEC-2024-0436",
|
||||
]
|
||||
@@ -1,3 +1,62 @@
|
||||
[alias]
|
||||
# Build the daemon with "firmware" profile and "ring" TLS backend.
|
||||
# Requires a cross-compiler (see github actions workflows) and is very slow to build.
|
||||
build-daemon-firmware = "build -p rayhunter-daemon --bin rayhunter-daemon --target armv7-unknown-linux-musleabihf --profile firmware --no-default-features --features ring-tls"
|
||||
# Build the daemon with "firmware-devel" profile and "rustcrypto" backend.
|
||||
# Works with just the Rust toolchain, and is medium-slow to build. Binaries are slightly larger.
|
||||
build-daemon-firmware-devel = "build -p rayhunter-daemon --bin rayhunter-daemon --target armv7-unknown-linux-musleabihf --profile firmware-devel"
|
||||
# Build rootshell for firmware
|
||||
build-rootshell-firmware = "build -p rootshell --bin rootshell --target armv7-unknown-linux-musleabihf --profile firmware"
|
||||
# Build rootshell for development
|
||||
build-rootshell-firmware-devel = "build -p rootshell --bin rootshell --target armv7-unknown-linux-musleabihf --profile firmware-devel"
|
||||
|
||||
[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"
|
||||
|
||||
[profile.firmware-devel]
|
||||
inherits = "release"
|
||||
opt-level = "s"
|
||||
lto = false
|
||||
|
||||
# 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
2
.git-blame-ignore-revs
Normal file
@@ -0,0 +1,2 @@
|
||||
9fe75ac961c57e508bf7488ce51d596750fa8d37
|
||||
76ffdf6bada515c9a5f63a600e6f1502288c147a
|
||||
10
.gitattributes
vendored
Normal file
10
.gitattributes
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# Files that are distributed onto the Rayhunter device always have to have
|
||||
# Unix-style line endings, even if the installer is built on Windows with
|
||||
# autocrlf enabled.
|
||||
# Using CRLF for the init scripts will make them fail to execute on TP-Link.
|
||||
# See https://github.com/EFForg/rayhunter/issues/489
|
||||
|
||||
dist/config.toml.in eol=lf
|
||||
dist/scripts/misc-daemon eol=lf
|
||||
dist/scripts/rayhunter_daemon eol=lf
|
||||
scripts/*.sh eol=lf
|
||||
25
.github/ISSUE_TEMPLATE/bug.yaml
vendored
Normal file
25
.github/ISSUE_TEMPLATE/bug.yaml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
name: Bug Report
|
||||
description: File a bug report.
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Prerequisites
|
||||
options:
|
||||
- label: I have read [CONTRIBUTING.md](https://github.com/EFForg/rayhunter/blob/main/CONTRIBUTING.md)
|
||||
required: true
|
||||
- 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
|
||||
10
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
10
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Frequently Asked Questions
|
||||
url: https://efforg.github.io/rayhunter/faq.html
|
||||
- name: Questions and community
|
||||
url: https://efforg.github.io/rayhunter/support-feedback-community.html
|
||||
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 on GitHub discussions or Mattermost
|
||||
- name: Rayhunter Security Policy
|
||||
url: https://github.com/EFForg/rayhunter/security/advisories/new
|
||||
about: Please report security vulnerabilities here.
|
||||
32
.github/ISSUE_TEMPLATE/feature.yaml
vendored
Normal file
32
.github/ISSUE_TEMPLATE/feature.yaml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or improvement to Rayhunter
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Prerequisites
|
||||
options:
|
||||
- label: I have read [CONTRIBUTING.md](https://github.com/EFForg/rayhunter/blob/main/CONTRIBUTING.md)
|
||||
required: true
|
||||
- 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..."
|
||||
53
.github/ISSUE_TEMPLATE/installer-bug.yaml
vendored
Normal file
53
.github/ISSUE_TEMPLATE/installer-bug.yaml
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
name: Installer Issue
|
||||
description: File an bug related to an installer issue.
|
||||
labels: ["bug", "installer"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Prerequisites
|
||||
options:
|
||||
- label: I have read [CONTRIBUTING.md](https://github.com/EFForg/rayhunter/blob/main/CONTRIBUTING.md)
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Rayhunter Version
|
||||
placeholder: 'v0.5.0'
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Device
|
||||
description: |
|
||||
What device are you trying to install Rayhunter on?
|
||||
options:
|
||||
- Orbic RC400L
|
||||
- Tplink M7350
|
||||
- Tplink M7310
|
||||
- Tmobile TMOHS1
|
||||
- Wingtech CT2MHS0
|
||||
- Pinephone
|
||||
- Other / I'm not sure
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Installer OS
|
||||
description: What operating system are running the installer from
|
||||
multiple: false
|
||||
options:
|
||||
- Linux
|
||||
- macOS
|
||||
- Windows
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the Issue
|
||||
description: |
|
||||
Please describe the issue you're having installing Rayhunter.
|
||||
Include the logs outputed by the installer program. If the installer
|
||||
is crashing, please try running the installer with `RUST_BACKTRACE=1`
|
||||
environment variable set so we can see exactly where the installer is
|
||||
crashing.
|
||||
validations:
|
||||
required: true
|
||||
12
.github/pull_request_template.md
vendored
Normal file
12
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
## 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.
|
||||
- [ ] [CONTRIBUTING.md](https://github.com/EFForg/rayhunter/blob/main/CONTRIBUTING.md) has been read.
|
||||
|
||||
You must check one of:
|
||||
- [ ] No generative AI (including LLMs) tools were used to create this PR.
|
||||
- [ ] Generative AI was used to create this PR. I certify that I have read and understand the code, and *that all comments and descriptions were authored by myself* and are not the product of generative AI.
|
||||
85
.github/workflows/build-release.yml
vendored
85
.github/workflows/build-release.yml
vendored
@@ -1,85 +0,0 @@
|
||||
name: Build Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, "release-*"]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
build_serial_and_check:
|
||||
strategy:
|
||||
matrix:
|
||||
platform:
|
||||
- os: ubuntu-latest
|
||||
serial_build_name: serial
|
||||
check_build_name: rayhunter-check
|
||||
- os: macos-latest
|
||||
serial_build_name: serial
|
||||
check_build_name: rayhunter-check
|
||||
runs-on: ${{ matrix.platform.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build serial
|
||||
run: cargo build --bin serial --release
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: serial-${{ matrix.platform.os }}
|
||||
path: ./target/release/${{ matrix.platform.serial_build_name }}
|
||||
if-no-files-found: error
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build check
|
||||
run: cargo build --bin rayhunter-check --release
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rayhunter-check-${{ matrix.platform.os }}
|
||||
path: ./target/release/${{ matrix.platform.check_build_name }}
|
||||
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_and_check
|
||||
- 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-check-*/rayhunter-check rayhunter-daemon/rayhunter-daemon
|
||||
- name: Setup release directory
|
||||
run: mv rayhunter-daemon/rayhunter-daemon rootshell/rootshell serial-* dist
|
||||
- name: Archive release directory
|
||||
run: tar -cvf release.tar -C dist .
|
||||
# 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
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
|
||||
636
.github/workflows/main.yml
vendored
Normal file
636
.github/workflows/main.yml
vendored
Normal file
@@ -0,0 +1,636 @@
|
||||
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 != '0' }}
|
||||
daemon_changed: ${{ steps.files_changed.outputs.daemon_count != '0' }}
|
||||
daemon_needed: ${{ steps.files_changed.outputs.daemon_count != '0' || steps.files_changed.outputs.installer_build != '0' }}
|
||||
web_changed: ${{ steps.files_changed.outputs.web_count != '0' }}
|
||||
docs_changed: ${{ steps.files_changed.outputs.docs_count != '0' || steps.files_changed.outputs.daemon_count != '0' }}
|
||||
installer_changed: ${{ steps.files_changed.outputs.installer_count != '0' }}
|
||||
installer_gui_changed: ${{ steps.files_changed.outputs.installer_gui_count != '0' }}
|
||||
rootshell_needed: ${{ steps.files_changed.outputs.rootshell_count != '0' || steps.files_changed.outputs.installer_build != '0' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: detect file changes
|
||||
id: files_changed
|
||||
run: |
|
||||
lcommit=${{ github.event.pull_request.base.sha || 'origin/main' }}
|
||||
|
||||
# If we are on main, if workflow/cargo config files changed, or if
|
||||
# the latest commit message contains "#build-all", run everything.
|
||||
# Use #build-all in a commit message to force a full build on a PR
|
||||
# branch (useful for testing release builds without merging to main).
|
||||
if [ ${GITHUB_REF} = 'refs/heads/main' ] || git diff --name-only $lcommit..HEAD | grep -qe ^.github/workflows/ -e ^.cargo || git log -1 --format='%s %b' | grep -qF '#build-all'
|
||||
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_build=forced >> "$GITHUB_OUTPUT"
|
||||
echo installer_count=forced >> "$GITHUB_OUTPUT"
|
||||
echo installer_gui_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 "rootshell_count=$(git diff --name-only $lcommit...HEAD | grep -e ^rootshell | wc -l)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
installer_count=$(git diff --name-only $lcommit...HEAD | grep -e ^installer/ | wc -l)
|
||||
installer_gui_count=$(git diff --name-only $lcommit...HEAD | grep -e ^installer-gui | wc -l)
|
||||
|
||||
if [ $installer_count != "0" ] || [ $installer_gui_count != "0" ]; then
|
||||
echo "installer_build=1" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "installer_build=0" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
echo "installer_count=$installer_count" >> "$GITHUB_OUTPUT"
|
||||
echo "installer_gui_count=$installer_gui_count" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
mdbook_test:
|
||||
name: Test mdBook Documentation builds
|
||||
needs: files_changed
|
||||
if: needs.files_changed.outputs.docs_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Install mdBook
|
||||
run: |
|
||||
cargo install mdbook --no-default-features --features search --vers "^0.4" --locked
|
||||
- name: Test mdBook
|
||||
run: mdbook test
|
||||
|
||||
mdbook_build:
|
||||
name: Build mdBook for Github Pages
|
||||
needs: mdbook_test
|
||||
if: ${{ github.ref == 'refs/heads/main' }}
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Install mdBook
|
||||
run: |
|
||||
cargo install mdbook --no-default-features --features search --vers "^0.4" --locked
|
||||
|
||||
- name: Build mdBook
|
||||
run: mdbook build
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: book
|
||||
path: book
|
||||
|
||||
check_and_test:
|
||||
needs: files_changed
|
||||
if: needs.files_changed.outputs.code_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
- 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
|
||||
|
||||
installer_gui_check:
|
||||
# we test the GUI installer separately to:
|
||||
# 1) mimic the default behavior of cargo commands for rayhunter devs where
|
||||
# installer-gui isn't one of the default workspace packages
|
||||
# 2) avoid slowing down development on changes unrelated to the GUI installer
|
||||
needs: files_changed
|
||||
if: needs.files_changed.outputs.installer_gui_changed == 'true'
|
||||
# we run this on macos simply because no additional OS packages need to be
|
||||
# installed
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
# we don't need to run cargo fmt here because both cargo fmt and cargo
|
||||
# fmt --all runs on all workspace packages so this is handled by
|
||||
# check_and_test above
|
||||
- name: Check
|
||||
run: NO_FIRMWARE_BIN=true cargo check --package installer-gui --verbose
|
||||
- name: Run clippy
|
||||
run: NO_FIRMWARE_BIN=true cargo clippy --package installer-gui --verbose
|
||||
|
||||
test_daemon_frontend:
|
||||
needs: files_changed
|
||||
if: needs.files_changed.outputs.web_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: daemon/web
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- run: npm install
|
||||
- run: npm run lint
|
||||
- run: npm run check
|
||||
- run: npm run test
|
||||
|
||||
test_installer_frontend:
|
||||
needs: files_changed
|
||||
if: needs.files_changed.outputs.installer_gui_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: installer-gui
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- run: npm install
|
||||
- run: npm run lint
|
||||
- run: npm run check
|
||||
|
||||
windows_installer_check_and_test:
|
||||
needs: files_changed
|
||||
if: needs.files_changed.outputs.installer_changed == 'true'
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- 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 == 'true'
|
||||
needs:
|
||||
- check_and_test
|
||||
- files_changed
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
matrix:
|
||||
platform:
|
||||
- name: linux-x64
|
||||
os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-musl
|
||||
- name: linux-armv7
|
||||
os: ubuntu-latest
|
||||
target: armv7-unknown-linux-musleabi
|
||||
- name: linux-aarch64
|
||||
os: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-musl
|
||||
- name: macos-arm
|
||||
os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
- name: macos-intel
|
||||
os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
- name: windows-x86_64
|
||||
os: windows-latest
|
||||
target: x86_64-pc-windows-gnu
|
||||
runs-on: ${{ matrix.platform.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- 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_needed == 'true'
|
||||
needs:
|
||||
- check_and_test
|
||||
- files_changed
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: armv7-unknown-linux-musleabihf
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Build rootshell (armv7)
|
||||
run: cargo build -p rootshell --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_needed == 'true'
|
||||
needs:
|
||||
- check_and_test
|
||||
- files_changed
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: armv7-unknown-linux-musleabihf
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Install ARM cross-compilation toolchain
|
||||
run: sudo apt-get update && sudo apt-get install -y gcc-arm-linux-gnueabihf
|
||||
- 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
|
||||
CC_armv7_unknown_linux_musleabihf=arm-linux-gnueabihf-gcc cargo build-daemon-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 == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
needs:
|
||||
- build_rayhunter
|
||||
- build_rootshell
|
||||
- files_changed
|
||||
- windows_installer_check_and_test
|
||||
strategy:
|
||||
matrix:
|
||||
platform:
|
||||
- name: linux-x64
|
||||
os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-musl
|
||||
- name: linux-armv7
|
||||
os: ubuntu-latest
|
||||
target: armv7-unknown-linux-musleabi
|
||||
- name: linux-aarch64
|
||||
os: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-musl
|
||||
- name: macos-arm
|
||||
os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
- name: macos-intel
|
||||
os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
- name: windows-x86_64
|
||||
os: windows-latest
|
||||
target: x86_64-pc-windows-gnu
|
||||
runs-on: ${{ matrix.platform.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- 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_installer_gui_linux:
|
||||
if: needs.files_changed.outputs.installer_gui_changed == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
needs:
|
||||
- build_rayhunter
|
||||
- build_rootshell
|
||||
- files_changed
|
||||
- installer_gui_check
|
||||
- test_installer_frontend
|
||||
strategy:
|
||||
matrix:
|
||||
platform:
|
||||
# we want to use the oldest supported version of ubuntu here to
|
||||
# maximize compatibility with older versions of glibc
|
||||
- name: linux-x64
|
||||
os: ubuntu-22.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- name: linux-aarch64
|
||||
os: ubuntu-22.04-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
runs-on: ${{ matrix.platform.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.platform.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Install tauri dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev xdg-utils
|
||||
- name: Build GUI installer
|
||||
shell: bash
|
||||
run: |
|
||||
cd installer-gui
|
||||
npm install
|
||||
npm run tauri build -- --target ${{ matrix.platform.target }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gui-installer-${{ matrix.platform.name }}-appimage
|
||||
path: target/${{ matrix.platform.target }}/release/bundle/appimage/*.AppImage
|
||||
if-no-files-found: error
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gui-installer-${{ matrix.platform.name }}-deb
|
||||
path: target/${{ matrix.platform.target }}/release/bundle/deb/*.deb
|
||||
if-no-files-found: error
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gui-installer-${{ matrix.platform.name }}-rpm
|
||||
path: target/${{ matrix.platform.target }}/release/bundle/rpm/*.rpm
|
||||
if-no-files-found: error
|
||||
|
||||
build_installer_gui_macos:
|
||||
if: needs.files_changed.outputs.installer_gui_changed == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
needs:
|
||||
- build_rayhunter
|
||||
- build_rootshell
|
||||
- files_changed
|
||||
- installer_gui_check
|
||||
- test_installer_frontend
|
||||
strategy:
|
||||
matrix:
|
||||
platform:
|
||||
- name: macos-arm
|
||||
target: aarch64-apple-darwin
|
||||
- name: macos-intel
|
||||
target: x86_64-apple-darwin
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.platform.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Build GUI installer
|
||||
shell: bash
|
||||
run: |
|
||||
cd installer-gui
|
||||
npm install
|
||||
npm run tauri build -- --target ${{ matrix.platform.target }}
|
||||
cd ..
|
||||
mv "target/${{ matrix.platform.target }}/release/bundle/macos/"*.app .
|
||||
zip -r "rayhunter-installer-${{ matrix.platform.name }}.app.zip" ./*.app
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gui-installer-${{ matrix.platform.name }}-app
|
||||
path: ./*.app.zip
|
||||
if-no-files-found: error
|
||||
|
||||
build_installer_gui_windows:
|
||||
if: needs.files_changed.outputs.installer_gui_changed == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
needs:
|
||||
- build_rayhunter
|
||||
- build_rootshell
|
||||
- files_changed
|
||||
- installer_gui_check
|
||||
- test_installer_frontend
|
||||
env:
|
||||
TARGET: x86_64-pc-windows-msvc
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ env.TARGET }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Build GUI installer
|
||||
shell: bash
|
||||
run: |
|
||||
cd installer-gui
|
||||
npm install
|
||||
npm run tauri build -- --target ${{ env.TARGET }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gui-installer-msi
|
||||
path: target/${{ env.TARGET }}/release/bundle/msi/*.msi
|
||||
if-no-files-found: error
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gui-installer-exe
|
||||
path: target/${{ env.TARGET }}/release/bundle/nsis/*.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
|
||||
with:
|
||||
persist-credentials: false
|
||||
- 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${VERSION}-${{ matrix.platform }}"
|
||||
mkdir "$dest"
|
||||
# Handle installer with proper extension for Windows
|
||||
if [ "$platform" = "windows-x86_64" ]; then
|
||||
mv installer-$platform/installer.exe "$dest"/installer.exe
|
||||
else
|
||||
mv installer-$platform/installer "$dest"/installer
|
||||
fi
|
||||
cp -r rayhunter-check-* rayhunter-daemon dist/scripts "$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
|
||||
|
||||
openapi_build:
|
||||
if: needs.files_changed.outputs.docs_changed == 'true'
|
||||
needs:
|
||||
- files_changed
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: armv7-unknown-linux-musleabihf
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Build rayhunter-daemon openapi docs
|
||||
run: |
|
||||
mkdir -p daemon/web/build
|
||||
touch daemon/web/build/{favicon.png,index.html.gz,rayhunter_orca_only.png,rayhunter_text.png}
|
||||
cargo run --bin gen_api --features apidocs -- ./rayhunter-openapi.json
|
||||
- name: Make swagger folder
|
||||
run: |
|
||||
mkdir api-docs
|
||||
mv doc/swagger-ui.html api-docs/index.html
|
||||
mv rayhunter-openapi.json api-docs/
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: api-docs
|
||||
path: api-docs
|
||||
|
||||
github_pages_publish:
|
||||
name: Upload new documentation to Github Pages
|
||||
if: ${{ github.ref == 'refs/heads/main' }}
|
||||
permissions:
|
||||
pages: write
|
||||
contents: write
|
||||
id-token: write
|
||||
needs:
|
||||
- mdbook_build
|
||||
- openapi_build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
- name: Organize pages into directory
|
||||
run: cp -a api-docs book/
|
||||
- name: Upload pages
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: book
|
||||
- name: Deploy Github Pages
|
||||
uses: actions/deploy-pages@v4
|
||||
52
.github/workflows/release.yml
vendored
Normal file
52
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
# 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
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Ensure all Cargo.toml files have the same version defined.
|
||||
run: |
|
||||
defined_versions=$(find lib check daemon installer installer-gui rootshell telcom-parser -name Cargo.toml -exec grep ^version {} \; | sort -u | wc -l)
|
||||
find lib check daemon installer installer-gui 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
|
||||
with:
|
||||
persist-credentials: false
|
||||
- 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
2
.gitignore
vendored
@@ -1 +1,3 @@
|
||||
/target
|
||||
/book
|
||||
.DS_Store
|
||||
|
||||
1
CODE_OF_CONDUCT.md
Normal file
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).
|
||||
83
CONTRIBUTING.md
Normal file
83
CONTRIBUTING.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# How to contribute to Rayhunter
|
||||
|
||||
## Filing issues and starting discussions
|
||||
|
||||
Our issue tracker is [on GitHub](https://github.com/EFForg/rayhunter/issues).
|
||||
|
||||
- If your rayhunter has found an IMSI-catcher, we strongly encourage you to
|
||||
[send us that information
|
||||
privately.](https://efforg.github.io/rayhunter/faq.html#help-rayhunters-line-is-redorangeyellowdotteddashed-what-should-i-do) via Signal.
|
||||
|
||||
- Issues should be actionable. If you don't have a
|
||||
specific feature request or bug report, consider [creating a
|
||||
discussion](https://github.com/EFForg/rayhunter/discussions) or [joining our Mattermost](https://efforg.github.io/rayhunter/support-feedback-community.html) instead.
|
||||
|
||||
Example of a good bug report:
|
||||
|
||||
- "Installer broken on TP-Link M7350 v3.0"
|
||||
- "Display does not update to green after finding"
|
||||
- "The documentation is wrong" (though we encourage you to file a pull request directly)
|
||||
|
||||
Example of a good feature request:
|
||||
|
||||
- "Use LED on device XYZ for showing recording status"
|
||||
|
||||
Example of something that belongs into discussion:
|
||||
|
||||
- "In region XYZ, do I need an activated SIM?"
|
||||
- "Where to buy this device in region XYZ?"
|
||||
- "Can this device be supported?" While this is a valid feature
|
||||
request, we just get this request too often, and without some exploratory
|
||||
work done upfront it's often unclear initially if that device can be
|
||||
supported at all.
|
||||
|
||||
- The issue templates are mostly there to give you a clue what kind of
|
||||
information is needed from you, and whether your request belongs into the issue
|
||||
tracker. Fill them out to be on the safe side, but they are not mandatory.
|
||||
|
||||
## Contributing patches
|
||||
|
||||
To edit documentation or fix a bug, make a pull request. If you're about to
|
||||
write a substantial amount of code or implement a new feature, we strongly
|
||||
encourage you to talk to us before implementing it or check if any issues have
|
||||
been opened for it already. Otherwise there is a chance we will reject your
|
||||
contribution after you have spent time on it.
|
||||
|
||||
On the other hand, for small documentation fixes you can file a PR without
|
||||
filing an issue.
|
||||
|
||||
Otherwise:
|
||||
|
||||
- Refer to [installing from
|
||||
source](https://efforg.github.io/rayhunter/installing-from-source.html) for
|
||||
how to build Rayhunter from the git repository.
|
||||
|
||||
- Ensure that `cargo fmt` and `cargo clippy` have been run.
|
||||
|
||||
- If you add new features, please do your best to both write tests for and also
|
||||
manually test them. Our test coverage isn't great, but as new features are
|
||||
added we are trying to prevent it from becoming worse.
|
||||
|
||||
If you have any questions [feel free to open a discussion or chat with us on Mattermost.](https://efforg.github.io/rayhunter/support-feedback-community.html)
|
||||
|
||||
### Policy regarding AI-generated contributions:
|
||||
|
||||
- Please refrain from submissions that you haven't thoroughly understood, reviewed, and tested.
|
||||
- Please disclose if your contribution was AI-generated
|
||||
- Descriptions and comments should be made by you
|
||||
|
||||
You can read our [full policy](https://www.eff.org/about/opportunities/volunteer/coding-with-eff) and some writing on [our motivations](https://www.eff.org/deeplinks/2026/02/effs-policy-llm-assisted-contributions-our-open-source-projects).
|
||||
|
||||
## Making releases
|
||||
|
||||
This one is for maintainers of Rayhunter.
|
||||
|
||||
1. Make a PR changing the versions in `Cargo.toml` and other files.
|
||||
This could be automated better but right now it's manual. You can do this easily with sed:
|
||||
`sed -i "" -E 's/x.x.x/y.y.y/g' */Cargo.toml`
|
||||
|
||||
2. Merge PR and make a tag.
|
||||
|
||||
3. [Run release workflow.](https://github.com/EFForg/rayhunter/actions/workflows/release.yml)
|
||||
|
||||
4. Write changelog, edit it into the release, announce on mattermost.
|
||||
6303
Cargo.lock
generated
6303
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
17
Cargo.toml
17
Cargo.toml
@@ -2,9 +2,22 @@
|
||||
|
||||
members = [
|
||||
"lib",
|
||||
"bin",
|
||||
"serial",
|
||||
"daemon",
|
||||
"check",
|
||||
"rootshell",
|
||||
"telcom-parser",
|
||||
"installer",
|
||||
"installer-gui/src-tauri",
|
||||
]
|
||||
# at least for now, let's keep installer-gui out of the list of default
|
||||
# packages. installer-gui is still experimental and requires many new packages
|
||||
# both from cargo and the underlying operating system
|
||||
default-members = [
|
||||
"lib",
|
||||
"daemon",
|
||||
"check",
|
||||
"rootshell",
|
||||
"telcom-parser",
|
||||
"installer",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
96
README.md
96
README.md
@@ -1,97 +1,19 @@
|
||||
# Rayhunter
|
||||

|
||||
|
||||
```
|
||||
@@@@@@@ @@@@@@ @@@ @@@ @@@ @@@ @@@ @@@ @@@ @@@ @@@@@@@ @@@@@@@@ @@@@@@@
|
||||
@@! @@@ @@! @@@ @@! !@@ @@! @@@ @@! @@@ @@!@!@@@ @@! @@! @@! @@@
|
||||
@!@!!@! @!@!@!@! !@!@! @!@!@!@! @!@ !@! @!@@!!@! @!! @!!!:! @!@!!@!
|
||||
!!: :!! !!: !!! !!: !!: !!! !!: !!! !!: !!! !!: !!: !!: :!!
|
||||
: : : : : : .: : : : :.:: : :: : : : :: ::: : : :
|
||||

|
||||
|
||||
Rayhunter is a project for detecting IMSI catchers, also known as cell-site simulators or stingrays. It was first designed to run on a cheap mobile hotspot called the Orbic RC400L, but thanks to community efforts, it can [support some other devices as well](https://efforg.github.io/rayhunter/supported-devices.html).
|
||||
It's also designed to be as easy to install and use as possible, regardless of your level of technical skills, and to minimize false positives.
|
||||
|
||||
_ _ _ _ _ _ _ _
|
||||
)`'-.,_)`'-.,_)`'-.,_)`'-.,_)`'-.,_)`'-.,_)`'-.,_)`'-.,_
|
||||
→ Check out the [installation guide](https://efforg.github.io/rayhunter/installation.html) to get started.
|
||||
|
||||
O .
|
||||
O ' '
|
||||
o ' .
|
||||
o .'
|
||||
__________.-' '...___
|
||||
.-' ### '''...__
|
||||
/ a### ## ''--.._ ______
|
||||
'. # ######## ' .-'
|
||||
'-._ ..**********#### ___...---'''\ '
|
||||
'-._ __________...---''' \ l
|
||||
\ | apc '._|
|
||||
\__;
|
||||
```
|
||||

|
||||
→ To learn more about the aim of the project, and about IMSI catchers in general, please check out our [introductory blog post](https://www.eff.org/deeplinks/2025/03/meet-rayhunter-new-open-source-tool-eff-detect-cellular-spying).
|
||||
|
||||
Rayhunter is an IMSI Catcher Catcher for the Orbic mobile hotspot.
|
||||
→ For discussion, help, or to join the mattermost channel and get involved with the project and community check out the [many ways listed here](https://efforg.github.io/rayhunter/support-feedback-community.html)!
|
||||
|
||||
**THIS CODE IS PROOF OF CONCEPT AND SHOULD NOT BE RELIED UPON IN HIGH RISK SITUATIONS**
|
||||
→ To learn more about the project in general check out the [Rayhunter Book](https://efforg.github.io/rayhunter/).
|
||||
|
||||
Code is built and tested for the Orbic RC400L mobile hotspot, it may work on other orbics and other
|
||||
linux/qualcom devices but this is the only one we have tested on. Buy the orbic [using bezos bucks](https://www.amazon.com/gp/product/B09CLS6Z7X/)
|
||||
|
||||
## Setup
|
||||
|
||||
*NOTE: We don't currently support automated installs on windows, you will have to follow the manual install instructions below*
|
||||
|
||||
1. Download the latest [rayhunter release bundle](https://github.com/EFForg/rayhunter/releases) and extract it.
|
||||
2. Run the install script inside the bundle corresponding to your platform (`install-linux.sh`, `install-mac.sh`).
|
||||
3. Once finished, rayhunter should be running! You can verify this by visiting the web UI as described below.
|
||||
|
||||
## Usage
|
||||
|
||||
Once installed, rayhunter will run automatically whenever your Orbic device is running. It serves a web UI that provides some basic controls, such as being able to start/stop recordings, download captures, and view heuristic analyses of captures. You can access this UI in one of two ways:
|
||||
|
||||
1. Over wifi: Connect your phone/laptop to the Orbic's wifi network and visit `http://192.168.1.1:8080` (click past your browser warning you about the connection not being secure, rayhunter doesn't have HTTPS yet!)
|
||||
* Note that you'll need the Orbic's wifi password for this, which can be retrieved by pressing the "MENU" button on the device and opening the 2.4 GHz menu.
|
||||
2. Over usb: Connect the Orbic device to your laptop via usb. Run `adb forward tcp:8080 tcp:8080`, then visit `http://localhost:8080`. For this you will need to install the Android Debug Bridge (ADB) on your computer, you can copy the version that was downloaded inside the releases/platform-tools/` folder to somewhere else in your path or you can install it manually. You can find instructions for doing so on your platform [here](https://www.xda-developers.com/install-adb-windows-macos-linux/#how-to-set-up-adb-on-your-computer), (don't worry about instructions for installing it on a phone/device yet).
|
||||
|
||||
## Development
|
||||
* Install ADB on your computer using the instructions above, and make sure it's in your terminal's PATH
|
||||
* You can verify if ADB is in your PATH by running `which adb` in a terminal. If it prints the filepath to where ADB is installed, you're set! Otherwise, try following one of these guides:
|
||||
* [linux](https://askubuntu.com/questions/652936/adding-android-sdk-platform-tools-to-path-downloaded-from-umake)
|
||||
* [macOS](https://www.repeato.app/setting-up-adb-on-macos-a-step-by-step-guide/)
|
||||
* [Windows](https://medium.com/@yadav-ajay/a-step-by-step-guide-to-setting-up-adb-path-on-windows-0b833faebf18)
|
||||
|
||||
### If your are on x86 linux
|
||||
* on your linux laptop install rust the usual way and then install cross compiling dependences.
|
||||
* run `sudo apt install build-essential libc6-armhf-cross libc6-dev-armhf-cross gcc-arm-linux-gnueabihf`
|
||||
|
||||
* set up cross compliing for rust:
|
||||
```
|
||||
rustup target add x86_64-unknown-linux-gnu
|
||||
rustup target add armv7-unknown-linux-gnueabihf
|
||||
```
|
||||
|
||||
Now you can root your device and install rayhunter by running `./tools/install-dev.sh`
|
||||
|
||||
### If you are on windows or can't run the install scripts
|
||||
* Root your device on windows using the instructions here: https://xdaforums.com/t/resetting-verizon-orbic-speed-rc400l-firmware-flash-kajeet.4334899/#post-87855183
|
||||
|
||||
* Build for arm using `cargo build`
|
||||
|
||||
* Run tests using `cargo test_pc`
|
||||
|
||||
* Push the scripts in `scripts/` to /etc/init.d on device and make a directory called /data/rayhunter using `adb shell` (and sshell for your root shell if you followed the steps above)
|
||||
|
||||
* you also need to copy `config.toml.example` to /data/rayhunter/config.toml
|
||||
|
||||
* Then run `./make.sh` this will build the binary and push it over adb. Restart your device or run `/etc/init.d/rayhunter_daemon start` on the device and you are good to go.
|
||||
|
||||
* Write your code and write tests
|
||||
|
||||
* Build for arm using `cargo build`
|
||||
|
||||
* Run tests using `cargo test_pc`
|
||||
|
||||
* push to the device with `./make.sh`
|
||||
|
||||
## Documentation
|
||||
* Build docs locallly using `RUSTDOCFLAGS="--cfg docsrs" cargo doc --no-deps --all-features --open`
|
||||
|
||||
**LEGAL DISCLAIMER:** Use this program at your own risk. We beilieve running this program does not currently violate any laws or regulations in the United States. However, we are not responsible for civil or criminal liability resulting from the use of this software. If you are located outside of the US please consult with an attorney in your country to help you assess the legal risks of running this program.
|
||||
**LEGAL DISCLAIMER:** Use this program at your own risk. We believe running this program does not currently violate any laws or regulations in the United States. However, we are not responsible for civil or criminal liability resulting from the use of this software. If you are located outside of the US please consult with an attorney in your country to help you assess the legal risks of running this program.
|
||||
|
||||
*Good Hunting!*
|
||||
|
||||
5
SECURITY.md
Normal file
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"
|
||||
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"
|
||||
tempfile = "3.10.1"
|
||||
@@ -1,84 +0,0 @@
|
||||
use std::{collections::HashMap, future, path::PathBuf, pin::pin};
|
||||
use rayhunter::{analysis::analyzer::Harness, diag::DataType, qmdl::QmdlReader};
|
||||
use tokio::fs::{metadata, read_dir, File};
|
||||
use clap::Parser;
|
||||
use futures::TryStreamExt;
|
||||
|
||||
mod dummy_analyzer;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version, about)]
|
||||
struct Args {
|
||||
#[arg(short, long)]
|
||||
qmdl_path: PathBuf,
|
||||
|
||||
#[arg(long)]
|
||||
show_skipped: bool,
|
||||
|
||||
#[arg(long)]
|
||||
enable_dummy_analyzer: bool,
|
||||
}
|
||||
|
||||
async fn analyze_file(harness: &mut Harness, qmdl_path: &str, show_skipped: bool) {
|
||||
let qmdl_file = &mut File::open(&qmdl_path).await.expect("failed to open file");
|
||||
let file_size = qmdl_file.metadata().await.expect("failed to get QMDL file metadata").len();
|
||||
let mut qmdl_reader = QmdlReader::new(qmdl_file, Some(file_size as usize));
|
||||
let mut qmdl_stream = pin!(qmdl_reader.as_stream()
|
||||
.try_filter(|container| future::ready(container.data_type == DataType::UserSpace)));
|
||||
let mut skipped_reasons: HashMap<String, i32> = HashMap::new();
|
||||
let mut total_messages = 0;
|
||||
let mut warnings = 0;
|
||||
let mut skipped = 0;
|
||||
while let Some(container) = qmdl_stream.try_next().await.expect("failed getting QMDL container") {
|
||||
let row = harness.analyze_qmdl_messages(container);
|
||||
total_messages += 1;
|
||||
for reason in row.skipped_message_reasons {
|
||||
*skipped_reasons.entry(reason).or_insert(0) += 1;
|
||||
skipped += 1;
|
||||
}
|
||||
for analysis in row.analysis {
|
||||
for maybe_event in analysis.events {
|
||||
if let Some(event) = maybe_event {
|
||||
warnings += 1;
|
||||
println!("{}: {:?}", analysis.timestamp, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if show_skipped && skipped > 0 {
|
||||
println!("{}: messages skipped:", qmdl_path);
|
||||
for (reason, count) in skipped_reasons.iter() {
|
||||
println!(" - {}: \"{}\"", count, reason);
|
||||
}
|
||||
}
|
||||
println!("{}: {} messages analyzed, {} warnings, {} messages skipped", qmdl_path, total_messages, warnings, skipped);
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
env_logger::init();
|
||||
let args = Args::parse();
|
||||
|
||||
let mut harness = Harness::new_with_all_analyzers();
|
||||
if args.enable_dummy_analyzer {
|
||||
harness.add_analyzer(Box::new(dummy_analyzer::TestAnalyzer { count: 0 }));
|
||||
}
|
||||
println!("Analyzers:");
|
||||
for analyzer in harness.get_metadata().analyzers {
|
||||
println!(" - {}: {}", analyzer.name, analyzer.description);
|
||||
}
|
||||
|
||||
let metadata = metadata(&args.qmdl_path).await.expect("failed to get metadata");
|
||||
if metadata.is_dir() {
|
||||
let mut dir = read_dir(&args.qmdl_path).await.expect("failed to read dir");
|
||||
while let Some(entry) = dir.next_entry().await.expect("failed to get entry") {
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_str().unwrap();
|
||||
if name_str.ends_with(".qmdl") {
|
||||
analyze_file(&mut harness, entry.path().to_str().unwrap(), args.show_skipped).await;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
analyze_file(&mut harness, args.qmdl_path.to_str().unwrap(), args.show_skipped).await;
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
use crate::error::RayhunterError;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ConfigFile {
|
||||
qmdl_store_path: Option<String>,
|
||||
port: Option<u16>,
|
||||
debug_mode: Option<bool>,
|
||||
ui_level: Option<u8>,
|
||||
enable_dummy_analyzer: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Config {
|
||||
pub qmdl_store_path: String,
|
||||
pub port: u16,
|
||||
pub debug_mode: bool,
|
||||
pub ui_level: u8,
|
||||
pub enable_dummy_analyzer: bool,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Config {
|
||||
qmdl_store_path: "/data/rayhunter/qmdl".to_string(),
|
||||
port: 8080,
|
||||
debug_mode: false,
|
||||
ui_level: 1,
|
||||
enable_dummy_analyzer: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)?;
|
||||
parsed_config.qmdl_store_path.map(|v| config.qmdl_store_path = v);
|
||||
parsed_config.port.map(|v| config.port = v);
|
||||
parsed_config.debug_mode.map(|v| config.debug_mode = v);
|
||||
parsed_config.ui_level.map(|v| config.ui_level = v);
|
||||
parsed_config.enable_dummy_analyzer.map(|v| config.enable_dummy_analyzer = v);
|
||||
}
|
||||
Ok(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,243 +0,0 @@
|
||||
mod analysis;
|
||||
mod config;
|
||||
mod error;
|
||||
mod pcap;
|
||||
mod server;
|
||||
mod stats;
|
||||
mod qmdl_store;
|
||||
mod diag;
|
||||
mod framebuffer;
|
||||
mod dummy_analyzer;
|
||||
|
||||
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 analysis::{get_analysis_status, run_analysis_thread, start_analysis, AnalysisCtrlMessage, AnalysisStatus};
|
||||
use axum::response::Redirect;
|
||||
use diag::{get_analysis_report, start_recording, stop_recording, DiagDeviceCtrlMessage};
|
||||
use log::{info, error};
|
||||
use rayhunter::diag_device::DiagDevice;
|
||||
use axum::routing::{get, post};
|
||||
use axum::Router;
|
||||
use stats::get_qmdl_manifest;
|
||||
use tokio::sync::mpsc::{self, Sender, Receiver};
|
||||
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<()>,
|
||||
ui_update_tx: Sender<framebuffer::DisplayState>,
|
||||
diag_device_sender: Sender<DiagDeviceCtrlMessage>,
|
||||
analysis_sender: Sender<AnalysisCtrlMessage>,
|
||||
analysis_status_lock: Arc<RwLock<AnalysisStatus>>,
|
||||
) -> JoinHandle<()> {
|
||||
info!("spinning up server");
|
||||
let state = Arc::new(ServerState {
|
||||
qmdl_store_lock,
|
||||
diag_device_ctrl_sender: diag_device_sender,
|
||||
ui_update_sender: ui_update_tx,
|
||||
debug_mode: config.debug_mode,
|
||||
analysis_status_lock,
|
||||
analysis_sender,
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route("/api/pcap/*name", get(get_pcap))
|
||||
.route("/api/qmdl/*name", get(get_qmdl))
|
||||
.route("/api/system-stats", get(get_system_stats))
|
||||
.route("/api/qmdl-manifest", get(get_qmdl_manifest))
|
||||
.route("/api/start-recording", post(start_recording))
|
||||
.route("/api/stop-recording", post(stop_recording))
|
||||
.route("/api/analysis-report/*name", get(get_analysis_report))
|
||||
.route("/api/analysis", get(get_analysis_status))
|
||||
.route("/api/analysis/*name", post(start_analysis))
|
||||
.route("/", get(|| async { Redirect::permanent("/index.html") }))
|
||||
.route("/*path", get(serve_static))
|
||||
.with_state(state);
|
||||
let 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
|
||||
// debug mode.
|
||||
async fn init_qmdl_store(config: &config::Config) -> Result<RecordingStore, RayhunterError> {
|
||||
match (RecordingStore::exists(&config.qmdl_store_path).await?, config.debug_mode) {
|
||||
(true, _) => Ok(RecordingStore::load(&config.qmdl_store_path).await?),
|
||||
(false, false) => Ok(RecordingStore::create(&config.qmdl_store_path).await?),
|
||||
(false, true) => Err(RayhunterError::NoStoreDebugMode(config.qmdl_store_path.clone())),
|
||||
}
|
||||
}
|
||||
|
||||
// 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<()>,
|
||||
maybe_ui_shutdown_tx: Option<oneshot::Sender<()>>,
|
||||
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
|
||||
analysis_tx: Sender<AnalysisCtrlMessage>,
|
||||
) -> 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");
|
||||
if let Some(ui_shutdown_tx) = maybe_ui_shutdown_tx {
|
||||
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");
|
||||
analysis_tx.send(AnalysisCtrlMessage::Exit).await
|
||||
.expect("couldn't send Exit message to analysis thread");
|
||||
},
|
||||
Err(err) => {
|
||||
error!("Unable to listen for shutdown signal: {}", err);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn update_ui(task_tracker: &TaskTracker, config: &config::Config, mut ui_shutdown_rx: oneshot::Receiver<()>, mut ui_update_rx: Receiver<framebuffer::DisplayState>) -> JoinHandle<()> {
|
||||
static IMAGE_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/static/images/");
|
||||
let display_level = config.ui_level;
|
||||
if display_level == 0 {
|
||||
info!("Invisible mode, not spawning UI.");
|
||||
}
|
||||
|
||||
let mut display_color = framebuffer::Color565::Green;
|
||||
|
||||
task_tracker.spawn_blocking(move || {
|
||||
let mut fb: Framebuffer = Framebuffer::new();
|
||||
// this feels wrong, is there a more rusty way to do this?
|
||||
let mut img: Option<&[u8]> = None;
|
||||
if display_level == 2 {
|
||||
img = Some(IMAGE_DIR.get_file("orca.gif").expect("failed to read orca.gif").contents());
|
||||
} else if display_level == 3 {
|
||||
img = Some(IMAGE_DIR.get_file("eff.png").expect("failed to read eff.png").contents());
|
||||
}
|
||||
loop {
|
||||
match ui_shutdown_rx.try_recv() {
|
||||
Ok(_) => {
|
||||
info!("received UI shutdown");
|
||||
break;
|
||||
},
|
||||
Err(TryRecvError::Empty) => {},
|
||||
Err(e) => panic!("error receiving shutdown message: {e}")
|
||||
}
|
||||
match ui_update_rx.try_recv() {
|
||||
Ok(state) => {
|
||||
display_color = state.into();
|
||||
},
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {},
|
||||
Err(e) => error!("error receiving framebuffer update message: {e}")
|
||||
}
|
||||
|
||||
match display_level {
|
||||
2 => {
|
||||
fb.draw_gif(img.unwrap());
|
||||
},
|
||||
3 => {
|
||||
fb.draw_img(img.unwrap())
|
||||
},
|
||||
128 => {
|
||||
fb.draw_line(framebuffer::Color565::Cyan, 128);
|
||||
fb.draw_line(framebuffer::Color565::Pink, 102);
|
||||
fb.draw_line(framebuffer::Color565::White, 76);
|
||||
fb.draw_line(framebuffer::Color565::Pink, 50);
|
||||
fb.draw_line(framebuffer::Color565::Cyan, 25);
|
||||
},
|
||||
1 | _ => {
|
||||
fb.draw_line(display_color, 2);
|
||||
},
|
||||
};
|
||||
sleep(Duration::from_millis(1000));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), RayhunterError> {
|
||||
env_logger::init();
|
||||
|
||||
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();
|
||||
println!("R A Y H U N T E R 🐳");
|
||||
|
||||
let qmdl_store_lock = Arc::new(RwLock::new(init_qmdl_store(&config).await?));
|
||||
let (tx, rx) = mpsc::channel::<DiagDeviceCtrlMessage>(1);
|
||||
let (ui_update_tx, ui_update_rx) = mpsc::channel::<framebuffer::DisplayState>(1);
|
||||
let (analysis_tx, analysis_rx) = mpsc::channel::<AnalysisCtrlMessage>(5);
|
||||
let mut maybe_ui_shutdown_tx = None;
|
||||
if !config.debug_mode {
|
||||
let (ui_shutdown_tx, ui_shutdown_rx) = oneshot::channel();
|
||||
maybe_ui_shutdown_tx = Some(ui_shutdown_tx);
|
||||
let mut dev = DiagDevice::new().await
|
||||
.map_err(RayhunterError::DiagInitError)?;
|
||||
dev.config_logs().await
|
||||
.map_err(RayhunterError::DiagInitError)?;
|
||||
|
||||
info!("Starting Diag Thread");
|
||||
run_diag_read_thread(&task_tracker, dev, rx, ui_update_tx.clone(), qmdl_store_lock.clone(), config.enable_dummy_analyzer);
|
||||
info!("Starting UI");
|
||||
update_ui(&task_tracker, &config, ui_shutdown_rx, ui_update_rx);
|
||||
}
|
||||
let (server_shutdown_tx, server_shutdown_rx) = oneshot::channel::<()>();
|
||||
info!("create shutdown thread");
|
||||
let analysis_status_lock = Arc::new(RwLock::new(AnalysisStatus::default()));
|
||||
run_analysis_thread(&task_tracker, analysis_rx, qmdl_store_lock.clone(), analysis_status_lock.clone(), config.enable_dummy_analyzer);
|
||||
run_ctrl_c_thread(&task_tracker, tx.clone(), server_shutdown_tx, maybe_ui_shutdown_tx, qmdl_store_lock.clone(), analysis_tx.clone());
|
||||
run_server(&task_tracker, &config, qmdl_store_lock.clone(), server_shutdown_rx, ui_update_tx, tx, analysis_tx, analysis_status_lock).await;
|
||||
|
||||
task_tracker.close();
|
||||
task_tracker.wait().await;
|
||||
|
||||
info!("see you space cowboy...");
|
||||
Ok(())
|
||||
}
|
||||
171
bin/src/diag.rs
171
bin/src/diag.rs
@@ -1,171 +0,0 @@
|
||||
use std::pin::pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::header::CONTENT_TYPE;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use rayhunter::diag::DataType;
|
||||
use rayhunter::diag_device::DiagDevice;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::sync::mpsc::{Receiver, Sender};
|
||||
use rayhunter::qmdl::QmdlWriter;
|
||||
use log::{debug, error, info};
|
||||
use tokio::fs::File;
|
||||
use tokio_util::io::ReaderStream;
|
||||
use tokio_util::task::TaskTracker;
|
||||
use futures::{StreamExt, TryStreamExt};
|
||||
|
||||
use crate::framebuffer;
|
||||
use crate::qmdl_store::RecordingStore;
|
||||
use crate::server::ServerState;
|
||||
use crate::analysis::AnalysisWriter;
|
||||
|
||||
pub enum DiagDeviceCtrlMessage {
|
||||
StopRecording,
|
||||
StartRecording((QmdlWriter<File>, File)),
|
||||
Exit,
|
||||
}
|
||||
|
||||
pub fn run_diag_read_thread(
|
||||
task_tracker: &TaskTracker,
|
||||
mut dev: DiagDevice,
|
||||
mut qmdl_file_rx: Receiver<DiagDeviceCtrlMessage>,
|
||||
ui_update_sender: Sender<framebuffer::DisplayState>,
|
||||
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
|
||||
enable_dummy_analyzer: bool,
|
||||
) {
|
||||
task_tracker.spawn(async move {
|
||||
let (initial_qmdl_file, initial_analysis_file) = qmdl_store_lock.write().await.new_entry().await.expect("failed creating QMDL file entry");
|
||||
let mut maybe_qmdl_writer: Option<QmdlWriter<File>> = Some(QmdlWriter::new(initial_qmdl_file));
|
||||
let mut diag_stream = pin!(dev.as_stream().into_stream());
|
||||
let mut maybe_analysis_writer = Some(AnalysisWriter::new(initial_analysis_file, enable_dummy_analyzer).await
|
||||
.expect("failed to create analysis writer"));
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg = qmdl_file_rx.recv() => {
|
||||
match msg {
|
||||
Some(DiagDeviceCtrlMessage::StartRecording((new_writer, new_analysis_file))) => {
|
||||
maybe_qmdl_writer = Some(new_writer);
|
||||
if let Some(analysis_writer) = maybe_analysis_writer {
|
||||
analysis_writer.close().await.expect("failed to close analysis writer");
|
||||
}
|
||||
maybe_analysis_writer = Some(AnalysisWriter::new(new_analysis_file, enable_dummy_analyzer).await
|
||||
.expect("failed to write to analysis file"));
|
||||
},
|
||||
Some(DiagDeviceCtrlMessage::StopRecording) => {
|
||||
maybe_qmdl_writer = None;
|
||||
if let Some(analysis_writer) = maybe_analysis_writer {
|
||||
analysis_writer.close().await.expect("failed to close analysis writer");
|
||||
}
|
||||
maybe_analysis_writer = None;
|
||||
},
|
||||
// None means all the Senders have been dropped, so it's
|
||||
// time to go
|
||||
Some(DiagDeviceCtrlMessage::Exit) | None => {
|
||||
info!("Diag reader thread exiting...");
|
||||
if let Some(analysis_writer) = maybe_analysis_writer {
|
||||
analysis_writer.close().await.expect("failed to close analysis writer");
|
||||
}
|
||||
return Ok(())
|
||||
},
|
||||
}
|
||||
}
|
||||
maybe_container = diag_stream.next() => {
|
||||
match maybe_container.unwrap() {
|
||||
Ok(container) => {
|
||||
if container.data_type != DataType::UserSpace {
|
||||
debug!("skipping non-userspace diag messages...");
|
||||
continue;
|
||||
}
|
||||
// keep track of how many bytes were written to the QMDL file so we can read
|
||||
// a valid block of data from it in the HTTP server
|
||||
if let Some(qmdl_writer) = maybe_qmdl_writer.as_mut() {
|
||||
qmdl_writer.write_container(&container).await.expect("failed to write to QMDL writer");
|
||||
debug!("total QMDL bytes written: {}, updating manifest...", qmdl_writer.total_written);
|
||||
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||
let index = qmdl_store.current_entry.expect("DiagDevice had qmdl_writer, but QmdlStore didn't have current entry???");
|
||||
qmdl_store.update_entry_qmdl_size(index, qmdl_writer.total_written).await
|
||||
.expect("failed to update qmdl file size");
|
||||
debug!("done!");
|
||||
} else {
|
||||
debug!("no qmdl_writer set, continuing...");
|
||||
}
|
||||
|
||||
if let Some(analysis_writer) = maybe_analysis_writer.as_mut() {
|
||||
let analysis_output = analysis_writer.analyze(container).await
|
||||
.expect("failed to analyze container");
|
||||
let (analysis_file_len, heuristic_warning) = analysis_output;
|
||||
if heuristic_warning {
|
||||
info!("a heuristic triggered on this run!");
|
||||
ui_update_sender.send(framebuffer::DisplayState::WarningDetected).await
|
||||
.expect("couldn't send ui update message: {}");
|
||||
}
|
||||
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||
let index = qmdl_store.current_entry.expect("DiagDevice had qmdl_writer, but QmdlStore didn't have current entry???");
|
||||
qmdl_store.update_entry_analysis_size(index, analysis_file_len as usize).await
|
||||
.expect("failed to update analysis file size");
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
error!("error reading diag device: {}", err);
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn start_recording(State(state): State<Arc<ServerState>>) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||
if state.debug_mode {
|
||||
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
|
||||
}
|
||||
let mut qmdl_store = state.qmdl_store_lock.write().await;
|
||||
let (qmdl_file, analysis_file) = qmdl_store.new_entry().await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("couldn't create new qmdl entry: {}", e)))?;
|
||||
let qmdl_writer = QmdlWriter::new(qmdl_file);
|
||||
state.diag_device_ctrl_sender.send(DiagDeviceCtrlMessage::StartRecording((qmdl_writer, analysis_file))).await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("couldn't send stop recording message: {}", e)))?;
|
||||
state.ui_update_sender.send(framebuffer::DisplayState::Recording).await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("couldn't send ui update message: {}", e)))?;
|
||||
Ok((StatusCode::ACCEPTED, "ok".to_string()))
|
||||
}
|
||||
|
||||
pub async fn stop_recording(State(state): State<Arc<ServerState>>) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||
if state.debug_mode {
|
||||
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
|
||||
}
|
||||
let mut qmdl_store = state.qmdl_store_lock.write().await;
|
||||
qmdl_store.close_current_entry().await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("couldn't close current qmdl entry: {}", e)))?;
|
||||
state.diag_device_ctrl_sender.send(DiagDeviceCtrlMessage::StopRecording).await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("couldn't send stop recording message: {}", e)))?;
|
||||
state.ui_update_sender.send(framebuffer::DisplayState::Paused).await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("couldn't send ui update message: {}", e)))?;
|
||||
Ok((StatusCode::ACCEPTED, "ok".to_string()))
|
||||
}
|
||||
|
||||
pub async fn get_analysis_report(State(state): State<Arc<ServerState>>, Path(qmdl_name): Path<String>) -> Result<Response, (StatusCode, String)> {
|
||||
let qmdl_store = state.qmdl_store_lock.read().await;
|
||||
let (entry_index, _) = if qmdl_name == "live" {
|
||||
qmdl_store.get_current_entry().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"No QMDL data's being recorded to analyze, try starting a new recording!".to_string()
|
||||
))?
|
||||
} else {
|
||||
qmdl_store.entry_for_name(&qmdl_name).ok_or((
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("Couldn't find QMDL entry with name \"{}\"", qmdl_name)
|
||||
))?
|
||||
};
|
||||
let analysis_file = qmdl_store.open_entry_analysis(entry_index).await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)))?;
|
||||
let analysis_stream = ReaderStream::new(analysis_file);
|
||||
|
||||
let headers = [(CONTENT_TYPE, "application/x-ndjson")];
|
||||
let body = Body::from_stream(analysis_stream);
|
||||
Ok((headers, body).into_response())
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use rayhunter::telcom_parser::lte_rrc::{PCCH_MessageType, PCCH_MessageType_c1, PagingUE_Identity};
|
||||
|
||||
use rayhunter::analysis::analyzer::{Analyzer, Event, EventType, Severity};
|
||||
use rayhunter::analysis::information_element::{InformationElement, LteInformationElement};
|
||||
|
||||
pub struct TestAnalyzer{
|
||||
pub count: i32,
|
||||
}
|
||||
|
||||
impl Analyzer for TestAnalyzer{
|
||||
fn get_name(&self) -> Cow<str> {
|
||||
Cow::from("Example Analyzer")
|
||||
}
|
||||
|
||||
fn get_description(&self) -> Cow<str> {
|
||||
Cow::from("Always returns true, if you are seeing this you are either a developer or you are about to have problems.")
|
||||
}
|
||||
|
||||
fn analyze_information_element(&mut self, ie: &InformationElement) -> Option<Event> {
|
||||
self.count += 1;
|
||||
if self.count % 100 == 0 {
|
||||
return Some(Event {
|
||||
event_type: EventType::Informational ,
|
||||
message: "multiple of 100 events processed".to_string(),
|
||||
})
|
||||
}
|
||||
let InformationElement::LTE(LteInformationElement::PCCH(pcch_msg)) = ie else {
|
||||
return None;
|
||||
};
|
||||
let PCCH_MessageType::C1(PCCH_MessageType_c1::Paging(paging)) = &pcch_msg.message else {
|
||||
return None;
|
||||
};
|
||||
for record in &paging.paging_record_list.as_ref()?.0 {
|
||||
if let PagingUE_Identity::S_TMSI(_) = record.ue_identity {
|
||||
return Some(Event {
|
||||
event_type: EventType::QualitativeWarning { severity: Severity::Low },
|
||||
message: "TMSI was provided to cell".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
use image::{codecs::gif::GifDecoder, imageops::FilterType, AnimationDecoder, DynamicImage};
|
||||
use std::{io::Cursor, time::Duration};
|
||||
|
||||
const FB_PATH:&str = "/dev/fb0";
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
// TODO actually poll for this, maybe w/ fbset?
|
||||
struct Dimensions {
|
||||
height: u32,
|
||||
width: u32,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Copy, Clone)]
|
||||
pub enum Color565 {
|
||||
Red = 0b1111100000000000,
|
||||
Green = 0b0000011111100000,
|
||||
Blue = 0b0000000000011111,
|
||||
White = 0b1111111111111111,
|
||||
Black = 0b0000000000000000,
|
||||
Cyan = 0b0000011111111111,
|
||||
Yellow = 0b1111111111100000,
|
||||
Pink = 0b1111010010011111,
|
||||
}
|
||||
|
||||
pub enum DisplayState {
|
||||
Recording,
|
||||
Paused,
|
||||
WarningDetected,
|
||||
}
|
||||
|
||||
impl From<DisplayState> for Color565 {
|
||||
fn from(state: DisplayState) -> Self {
|
||||
match state {
|
||||
DisplayState::Paused => Color565::White,
|
||||
DisplayState::Recording => Color565::Green,
|
||||
DisplayState::WarningDetected => Color565::Red,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct Framebuffer<'a> {
|
||||
dimensions: Dimensions,
|
||||
path: &'a str,
|
||||
}
|
||||
|
||||
impl Framebuffer<'_>{
|
||||
pub const fn new() -> Self {
|
||||
Framebuffer{
|
||||
dimensions: Dimensions{height: 128, width: 128},
|
||||
path: FB_PATH,
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&mut self, img: DynamicImage) {
|
||||
let mut width = img.width();
|
||||
let mut height = img.height();
|
||||
let resized_img: DynamicImage;
|
||||
if height > self.dimensions.height ||
|
||||
width > self.dimensions.width {
|
||||
resized_img = img.resize( self.dimensions.width, self.dimensions.height, FilterType::CatmullRom);
|
||||
width = self.dimensions.width.min(resized_img.width());
|
||||
height = self.dimensions.height.min(resized_img.height());
|
||||
} else {
|
||||
resized_img = img;
|
||||
}
|
||||
let img_rgba8 = resized_img.as_rgba8().unwrap();
|
||||
let mut buf = Vec::new();
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let px = img_rgba8.get_pixel(x, y);
|
||||
let mut rgb565: u16 = (px[0] as u16 & 0b11111000) << 8;
|
||||
rgb565 |= (px[1] as u16 & 0b11111100) << 3;
|
||||
rgb565 |= (px[2] as u16) >> 3;
|
||||
buf.extend(rgb565.to_le_bytes());
|
||||
}
|
||||
}
|
||||
std::fs::write(self.path, &buf).unwrap();
|
||||
}
|
||||
|
||||
pub fn draw_gif(&mut self, img_buffer: &[u8]) {
|
||||
// this is dumb and i'm sure there's a better way to loop this
|
||||
let cursor = Cursor::new(img_buffer);
|
||||
let decoder = GifDecoder::new(cursor).unwrap();
|
||||
for maybe_frame in decoder.into_frames() {
|
||||
let frame = maybe_frame.unwrap();
|
||||
let (numerator, _) = frame.delay().numer_denom_ms();
|
||||
let img = DynamicImage::from(frame.into_buffer());
|
||||
self.write(img);
|
||||
std::thread::sleep(Duration::from_millis(numerator as u64));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw_img(&mut self, img_buffer: &[u8]) {
|
||||
let img = image::load_from_memory(img_buffer).unwrap();
|
||||
self.write(img);
|
||||
}
|
||||
|
||||
pub fn draw_line(&mut self, color: Color565, height: u32){
|
||||
let px_num= height * self.dimensions.width;
|
||||
let color: u16 = color as u16;
|
||||
let mut buffer: Vec<u8> = Vec::new();
|
||||
for _ in 0..px_num {
|
||||
buffer.extend(color.to_le_bytes());
|
||||
}
|
||||
std::fs::write(self.path, &buffer).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -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_index, entry) = qmdl_store.entry_for_name(&qmdl_name)
|
||||
.ok_or((StatusCode::NOT_FOUND, format!("couldn't find qmdl file with name {}", qmdl_name)))?;
|
||||
if entry.qmdl_size_bytes == 0 {
|
||||
return Err((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"QMDL file is empty, try again in a bit!".to_string()
|
||||
));
|
||||
}
|
||||
let qmdl_size_bytes = entry.qmdl_size_bytes;
|
||||
let qmdl_file = qmdl_store.open_entry_qmdl(entry_index).await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)))?;
|
||||
// the QMDL reader should stop at the last successfully written data chunk
|
||||
// (entry.size_bytes)
|
||||
let (reader, writer) = duplex(1024);
|
||||
let mut pcap_writer = GsmtapPcapWriter::new(writer).await.unwrap();
|
||||
pcap_writer.write_iface_header().await.unwrap();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut reader = QmdlReader::new(qmdl_file, Some(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,335 +0,0 @@
|
||||
use chrono::{DateTime, Local};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use thiserror::Error;
|
||||
use tokio::{
|
||||
fs::{self, try_exists, File, OpenOptions},
|
||||
io::AsyncWriteExt,
|
||||
};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum RecordingStoreError {
|
||||
#[error("Can't close an entry when there's no current entry")]
|
||||
NoCurrentEntry,
|
||||
#[error("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_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)?;
|
||||
self.update_entry_analysis_size(entry_index, 0)
|
||||
.await?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
// Unsets the current entry
|
||||
pub async fn close_current_entry(&mut self) -> Result<(), RecordingStoreError> {
|
||||
match self.current_entry {
|
||||
Some(_) => {
|
||||
self.current_entry = None;
|
||||
Ok(())
|
||||
}
|
||||
None => Err(RecordingStoreError::NoCurrentEntry),
|
||||
}
|
||||
}
|
||||
|
||||
// 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<(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]))
|
||||
}
|
||||
}
|
||||
|
||||
#[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_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);
|
||||
}
|
||||
}
|
||||
@@ -1,92 +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::fs::File;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::sync::mpsc::Sender;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio_util::io::ReaderStream;
|
||||
use include_dir::{include_dir, Dir};
|
||||
|
||||
use crate::{framebuffer, DiagDeviceCtrlMessage};
|
||||
use crate::analysis::{AnalysisCtrlMessage, AnalysisStatus};
|
||||
use crate::qmdl_store::RecordingStore;
|
||||
|
||||
pub struct ServerState {
|
||||
pub qmdl_store_lock: Arc<RwLock<RecordingStore>>,
|
||||
pub diag_device_ctrl_sender: Sender<DiagDeviceCtrlMessage>,
|
||||
pub ui_update_sender: Sender<framebuffer::DisplayState>,
|
||||
pub analysis_status_lock: Arc<RwLock<AnalysisStatus>>,
|
||||
pub analysis_sender: Sender<AnalysisCtrlMessage>,
|
||||
pub debug_mode: bool
|
||||
}
|
||||
|
||||
pub async fn get_qmdl(State(state): State<Arc<ServerState>>, Path(qmdl_name): Path<String>) -> Result<Response, (StatusCode, String)> {
|
||||
let qmdl_store = state.qmdl_store_lock.read().await;
|
||||
let (entry_index, entry) = qmdl_store.entry_for_name(&qmdl_name)
|
||||
.ok_or((StatusCode::NOT_FOUND, format!("couldn't find qmdl file with name {}", qmdl_name)))?;
|
||||
let qmdl_file = qmdl_store.open_entry_qmdl(entry_index).await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("error opening QMDL file: {}", e)))?;
|
||||
let 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(State(state): State<Arc<ServerState>>, Path(path): Path<String>) -> impl IntoResponse {
|
||||
let path = path.trim_start_matches('/');
|
||||
let mime_type = mime_guess::from_path(path).first_or_text_plain();
|
||||
|
||||
// if we're in debug mode, return the files from the build directory so we
|
||||
// don't have to rebuild every time the JS/HTML change
|
||||
if state.debug_mode {
|
||||
let mut build_path = std::path::PathBuf::new();
|
||||
build_path.push("bin");
|
||||
build_path.push("static");
|
||||
for part in path.split("/") {
|
||||
build_path.push(part);
|
||||
}
|
||||
return match File::open(build_path).await {
|
||||
Ok(mut file) => {
|
||||
let mut body = String::new();
|
||||
file.read_to_string(&mut body).await.expect("failed to read file");
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_str(mime_type.as_ref()).unwrap(),
|
||||
)
|
||||
.body(Body::from(body))
|
||||
.unwrap()
|
||||
},
|
||||
Err(_) => Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
};
|
||||
}
|
||||
|
||||
match STATIC_DIR.get_file(path) {
|
||||
None => Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.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(),
|
||||
}
|
||||
}
|
||||
128
bin/src/stats.rs
128
bin/src/stats.rs
@@ -1,128 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::qmdl_store::ManifestEntry;
|
||||
use crate::server::ServerState;
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use log::error;
|
||||
use serde::Serialize;
|
||||
use tokio::process::Command;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SystemStats {
|
||||
pub disk_stats: DiskStats,
|
||||
pub memory_stats: MemoryStats,
|
||||
}
|
||||
|
||||
impl SystemStats {
|
||||
pub async fn new(qmdl_path: &str) -> Result<Self, String> {
|
||||
Ok(Self {
|
||||
disk_stats: DiskStats::new(qmdl_path).await?,
|
||||
memory_stats: MemoryStats::new().await?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DiskStats {
|
||||
partition: String,
|
||||
total_size: String,
|
||||
used_size: String,
|
||||
available_size: String,
|
||||
used_percent: String,
|
||||
mounted_on: String,
|
||||
}
|
||||
|
||||
impl DiskStats {
|
||||
// runs "df -h <qmdl_path>" to get storage statistics for the partition containing
|
||||
// the QMDL file
|
||||
pub async fn new(qmdl_path: &str) -> Result<Self, String> {
|
||||
let mut df_cmd = Command::new("df");
|
||||
df_cmd.arg("-h");
|
||||
df_cmd.arg(qmdl_path);
|
||||
let stdout = get_cmd_output(df_cmd).await?;
|
||||
let mut parts = stdout.split_whitespace().skip(7).to_owned();
|
||||
Ok(Self {
|
||||
partition: parts.next().ok_or("error parsing df output")?.to_string(),
|
||||
total_size: parts.next().ok_or("error parsing df output")?.to_string(),
|
||||
used_size: parts.next().ok_or("error parsing df output")?.to_string(),
|
||||
available_size: parts.next().ok_or("error parsing df output")?.to_string(),
|
||||
used_percent: parts.next().ok_or("error parsing df output")?.to_string(),
|
||||
mounted_on: parts.next().ok_or("error parsing df output")?.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MemoryStats {
|
||||
total: String,
|
||||
used: String,
|
||||
free: String,
|
||||
}
|
||||
|
||||
// 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
|
||||
.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()));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
}
|
||||
|
||||
impl MemoryStats {
|
||||
// runs "free -k" and parses the output to retrieve memory stats
|
||||
pub async fn new() -> Result<Self, String> {
|
||||
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()
|
||||
.flat_map(|part| part.parse::<usize>());
|
||||
Ok(Self {
|
||||
total: humanize_kb(numbers.next().ok_or("error parsing free output")?),
|
||||
used: humanize_kb(numbers.next().ok_or("error parsing free output")?),
|
||||
free: humanize_kb(numbers.next().ok_or("error parsing free output")?),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
format!("{:.1}M", kb as f64 / 1024.0)
|
||||
}
|
||||
|
||||
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);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"error getting system stats".to_string()
|
||||
))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ManifestStats {
|
||||
pub entries: Vec<ManifestEntry>,
|
||||
pub current_entry: Option<ManifestEntry>,
|
||||
}
|
||||
|
||||
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));
|
||||
Ok(Json(ManifestStats {
|
||||
entries,
|
||||
current_entry,
|
||||
}))
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
td,
|
||||
th {
|
||||
border: 1px solid rgb(190, 190, 190);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
td {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
tr:nth-child(even) {
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
th[scope='col'] {
|
||||
background-color: #696969;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
th[scope='row'] {
|
||||
background-color: #d7d9f2;
|
||||
}
|
||||
|
||||
tr.current {
|
||||
background-color: #53fe7b;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
tr.warning {
|
||||
background-color: #fe537b;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
caption {
|
||||
padding: 10px;
|
||||
caption-side: bottom;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
border: 2px solid rgb(200, 200, 200);
|
||||
letter-spacing: 1px;
|
||||
font-family: sans-serif;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>rayhunter</title>
|
||||
<link rel="stylesheet" type="text/css" href="css/style.css">
|
||||
<script src="js/main.js"></script>
|
||||
<script>
|
||||
async function repeatedlyPopulate() {
|
||||
await populateDivs();
|
||||
setTimeout(repeatedlyPopulate, 1000);
|
||||
}
|
||||
window.onload = function() {
|
||||
repeatedlyPopulate();
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div>
|
||||
<button onclick="startRecording()">Start Recording</button>
|
||||
<button onclick="stopRecording()">Stop Recording</button>
|
||||
</div>
|
||||
<table id="qmdl-manifest-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">Date Started</th>
|
||||
<th scope="col">Date of Last Message</th>
|
||||
<th scope="col">Size (bytes)</th>
|
||||
<th scope="col">PCAP</th>
|
||||
<th scope="col">QMDL</th>
|
||||
<th scope="col">Analysis Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
<div>
|
||||
<h3>Live System stats</h3>
|
||||
<pre id="system-stats">Loading...</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Analysis Report of Current Capture</h3>
|
||||
<pre id="analysis-report">Loading...</pre>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,206 +0,0 @@
|
||||
const STATUS_RUNNING = 'running';
|
||||
const STATUS_QUEUED = 'queued';
|
||||
const STATUS_NEEDS_UPDATE = 'needs-update';
|
||||
const STATUS_COMPLETE = 'complete';
|
||||
|
||||
async function populateDivs() {
|
||||
const systemStats = await getSystemStats();
|
||||
const systemStatsDiv = document.getElementById('system-stats');
|
||||
systemStatsDiv.innerHTML = JSON.stringify(systemStats, null, 2);
|
||||
|
||||
const analysisReportDiv = document.getElementById('analysis-report');
|
||||
try {
|
||||
const analysisReport = await getAnalysisReport('live');
|
||||
analysisReportDiv.innerHTML = JSON.stringify(analysisReport, null, 2);
|
||||
} catch (e) {
|
||||
analysisReportDiv.innerHTML = e.toString();
|
||||
}
|
||||
|
||||
const qmdlManifest = await getQmdlManifest();
|
||||
await updateAnalysisStatus(qmdlManifest);
|
||||
await updateAnalysisResults(qmdlManifest);
|
||||
updateQmdlManifestTable(qmdlManifest);
|
||||
}
|
||||
|
||||
function setStatus(qmdlManifest, name, status) {
|
||||
// ignore qmdlManifest.current_entry, it's always running
|
||||
for (const entry of qmdlManifest.entries) {
|
||||
if (entry.name === name) {
|
||||
entry['status'] = status;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAnalysisStatus(qmdlManifest) {
|
||||
const status = JSON.parse(await req('GET', '/api/analysis'));
|
||||
if (status.running) {
|
||||
setStatus(qmdlManifest, status.running, STATUS_RUNNING);
|
||||
}
|
||||
for (const queued in status.queued) {
|
||||
setStatus(qmdlManifest, queued, STATUS_QUEUED);
|
||||
}
|
||||
}
|
||||
|
||||
function parseNewlineDelimitedJSON(inputStr) {
|
||||
const lines = inputStr.split('\n');
|
||||
const result = [];
|
||||
let currentLine = '';
|
||||
while (lines.length > 0) {
|
||||
currentLine += lines.shift();
|
||||
try {
|
||||
const entry = JSON.parse(currentLine);
|
||||
result.push(entry);
|
||||
currentLine = '';
|
||||
// if this chunk wasn't valid JSON, there was an escaped newline in the
|
||||
// JSON line, so simply continue to the next one
|
||||
} catch (e) {}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function updateEntryAnalysisResult(entry) {
|
||||
entry.analysis = {
|
||||
warnings: [],
|
||||
};
|
||||
const report = parseNewlineDelimitedJSON(await req('GET', `/api/analysis-report/${entry.name}`));
|
||||
for (const row of report) {
|
||||
if (row["analysis"]) {
|
||||
const timestamp = new Date(row["timestamp"]);
|
||||
const analysis = row["analysis"];
|
||||
for (const warning of analysis) {
|
||||
entry.analysis.warnings.push({
|
||||
timestamp,
|
||||
warning,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if (entry.analysis.warnings.length === 0) {
|
||||
entry.analysis_result = `0 warnings!`;
|
||||
} else {
|
||||
entry.analysis_result = `!!! ${entry.analysis.warnings.length} warnings !!!`;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAnalysisResults(qmdlManifest) {
|
||||
if (qmdlManifest.current_entry) {
|
||||
await updateEntryAnalysisResult(qmdlManifest.current_entry);
|
||||
}
|
||||
for (const entry of qmdlManifest.entries) {
|
||||
if (entry.status === STATUS_NEEDS_UPDATE) {
|
||||
await updateEntryAnalysisResult(entry);
|
||||
entry.status = STATUS_COMPLETE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateQmdlManifestTable(manifest) {
|
||||
const table = document.getElementById('qmdl-manifest-table');
|
||||
const numRows = table.rows.length;
|
||||
for (let i=1; i<numRows; i++) {
|
||||
table.deleteRow(1);
|
||||
}
|
||||
if (manifest.current_entry) {
|
||||
const row = createEntryRow(manifest.current_entry, true);
|
||||
row.classList.add('current');
|
||||
table.appendChild(row)
|
||||
}
|
||||
for (let entry of manifest.entries) {
|
||||
table.appendChild(createEntryRow(entry), false);
|
||||
}
|
||||
}
|
||||
|
||||
function createLink(uri, text) {
|
||||
const link = document.createElement('a');
|
||||
link.href = uri;
|
||||
link.innerText = text;
|
||||
return link;
|
||||
}
|
||||
|
||||
function createEntryRow(entry, isCurrent) {
|
||||
const row = document.createElement('tr');
|
||||
const name = document.createElement('th');
|
||||
name.scope = 'row';
|
||||
name.innerText = entry.name;
|
||||
row.appendChild(name);
|
||||
|
||||
for (const key of ['start_time', 'last_message_time', 'qmdl_size_bytes']) {
|
||||
const td = document.createElement('td');
|
||||
td.innerText = entry[key];
|
||||
row.appendChild(td);
|
||||
}
|
||||
|
||||
const pcapTd = document.createElement('td');
|
||||
pcapTd.appendChild(createLink(`/api/pcap/${entry.name}`, 'pcap'));
|
||||
row.appendChild(pcapTd);
|
||||
|
||||
const qmdlTd = document.createElement('td');
|
||||
qmdlTd.appendChild(createLink(`/api/qmdl/${entry.name}`, 'qmdl'));
|
||||
row.appendChild(qmdlTd);
|
||||
|
||||
const analysisResult = document.createElement('td');
|
||||
analysisResult.innerText = entry.analysis_result;
|
||||
if (entry.analysis.warnings.length > 0) {
|
||||
row.classList.add("warning");
|
||||
}
|
||||
row.appendChild(analysisResult);
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
async function getAnalysisReport(name) {
|
||||
const rows = await req('GET', `/api/analysis-report/${name}`);
|
||||
return rows.split('\n')
|
||||
.filter(row => row.length > 0)
|
||||
.map(row => JSON.parse(row));
|
||||
}
|
||||
|
||||
async function getSystemStats() {
|
||||
return JSON.parse(await req('GET', '/api/system-stats'));
|
||||
}
|
||||
|
||||
async function getQmdlManifest() {
|
||||
const manifest = JSON.parse(await req('GET', '/api/qmdl-manifest'));
|
||||
if (manifest.current_entry) {
|
||||
manifest.current_entry.status = STATUS_NEEDS_UPDATE;
|
||||
manifest.current_entry.analysis_result = 'Waiting...';
|
||||
manifest.current_entry.start_time = new Date(manifest.current_entry.start_time);
|
||||
if (manifest.current_entry.last_message_time === undefined) {
|
||||
manifest.current_entry.last_message_time = "N/A";
|
||||
} else {
|
||||
manifest.current_entry.last_message_time = new Date(manifest.current_entry.last_message_time);
|
||||
}
|
||||
}
|
||||
for (entry of manifest.entries) {
|
||||
entry.status = STATUS_NEEDS_UPDATE;
|
||||
entry.analysis_result = 'Waiting...';
|
||||
entry.start_time = new Date(entry.start_time);
|
||||
entry.last_message_time = new Date(entry.last_message_time);
|
||||
}
|
||||
// sort them in reverse chronological order
|
||||
manifest.entries.reverse();
|
||||
return manifest;
|
||||
}
|
||||
|
||||
async function startRecording() {
|
||||
await req('POST', '/api/start-recording');
|
||||
populateDivs();
|
||||
}
|
||||
|
||||
async function stopRecording() {
|
||||
await req('POST', '/api/stop-recording');
|
||||
populateDivs();
|
||||
}
|
||||
|
||||
async function req(method, url) {
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
});
|
||||
const body = await response.text();
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
return body;
|
||||
} else {
|
||||
throw new Error(body);
|
||||
}
|
||||
}
|
||||
9
book.toml
Normal file
9
book.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[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}"
|
||||
additional-css = ["doc/custom.css"]
|
||||
13
check/Cargo.toml
Normal file
13
check/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "rayhunter-check"
|
||||
version = "0.10.2"
|
||||
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"] }
|
||||
walkdir = "2.5.0"
|
||||
209
check/src/main.rs
Normal file
209
check/src/main.rs
Normal file
@@ -0,0 +1,209 @@
|
||||
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, help = "A file or directory of packet captures")]
|
||||
path: PathBuf,
|
||||
|
||||
#[arg(short = 'P', long, help = "Convert qmdl files to pcap before analysis")]
|
||||
pcapify: bool,
|
||||
|
||||
#[arg(long, help = "Show why some packets were skipped during analysis")]
|
||||
show_skipped: bool,
|
||||
|
||||
#[arg(short, long, help = "Only print warnings/errors to stdout")]
|
||||
quiet: bool,
|
||||
|
||||
#[arg(short, long, help = "Show debug messages")]
|
||||
debug: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Report {
|
||||
skipped_reasons: HashMap<String, u32>,
|
||||
total_messages: u32,
|
||||
warnings: u32,
|
||||
skipped: u32,
|
||||
file_path: String,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
fn new(file_path: &str) -> Self {
|
||||
Report {
|
||||
file_path: file_path.to_string(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn process_row(&mut self, row: AnalysisRow) {
|
||||
self.total_messages += 1;
|
||||
if let Some(reason) = row.skipped_message_reason {
|
||||
*self.skipped_reasons.entry(reason).or_insert(0) += 1;
|
||||
self.skipped += 1;
|
||||
return;
|
||||
}
|
||||
for maybe_event in row.events {
|
||||
let Some(event) = maybe_event else { continue };
|
||||
let Some(timestamp) = row.packet_timestamp else {
|
||||
continue;
|
||||
};
|
||||
match event.event_type {
|
||||
EventType::Informational => {
|
||||
info!("{}: INFO - {} {}", self.file_path, timestamp, event.message,);
|
||||
}
|
||||
EventType::Low | EventType::Medium | EventType::High => {
|
||||
warn!(
|
||||
"{}: WARNING (Severity: {:?}) - {} {}",
|
||||
self.file_path, event.event_type, timestamp, event.message,
|
||||
);
|
||||
self.warnings += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn print_summary(&self, show_skipped: bool) {
|
||||
if show_skipped && self.skipped > 0 {
|
||||
info!("{}: messages skipped:", self.file_path);
|
||||
for (reason, count) in self.skipped_reasons.iter() {
|
||||
info!(" - {count}: \"{reason}\"");
|
||||
}
|
||||
}
|
||||
info!(
|
||||
"{}: {} messages analyzed, {} warnings, {} messages skipped",
|
||||
self.file_path, self.total_messages, self.warnings, self.skipped
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn analyze_pcap(pcap_path: &str, show_skipped: bool) {
|
||||
let mut harness = Harness::new_with_config(&AnalyzerConfig::default());
|
||||
let pcap_file = &mut File::open(&pcap_path).await.expect("failed to open file");
|
||||
let mut pcap_reader = PcapNgReader::new(pcap_file)
|
||||
.await
|
||||
.expect("failed to read PCAP file");
|
||||
let mut report = Report::new(pcap_path);
|
||||
while let Some(Ok(block)) = pcap_reader.next_block().await {
|
||||
let row = match block {
|
||||
Block::EnhancedPacket(packet) => harness.analyze_pcap_packet(packet),
|
||||
other => {
|
||||
debug!("{pcap_path}: skipping pcap packet {other:?}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
report.process_row(row);
|
||||
}
|
||||
report.print_summary(show_skipped);
|
||||
}
|
||||
|
||||
async fn analyze_qmdl(qmdl_path: &str, show_skipped: bool) {
|
||||
let mut harness = Harness::new_with_config(&AnalyzerConfig::default());
|
||||
let qmdl_file = &mut File::open(&qmdl_path).await.expect("failed to open file");
|
||||
let compressed = qmdl_path.ends_with(".gz");
|
||||
let qmdl_reader = QmdlReader::new(qmdl_file, compressed, None);
|
||||
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 compressed = qmdl_path.ends_with(".gz");
|
||||
let qmdl_file_size = qmdl_file.metadata().await.unwrap().len();
|
||||
let mut qmdl_reader = QmdlReader::new(qmdl_file, compressed, 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
|
||||
};
|
||||
rayhunter::init_logging(level);
|
||||
|
||||
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();
|
||||
if name_str.ends_with(".qmdl") || name_str.ends_with(".qmdl.gz") {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
45
daemon/Cargo.toml
Normal file
45
daemon/Cargo.toml
Normal file
@@ -0,0 +1,45 @@
|
||||
[package]
|
||||
name = "rayhunter-daemon"
|
||||
version = "0.10.2"
|
||||
edition = "2024"
|
||||
rust-version = "1.88.0"
|
||||
|
||||
[lib]
|
||||
name = "rayhunter_daemon"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "gen_api"
|
||||
path = "src/bin/gen_api.rs"
|
||||
required-features = ["apidocs"]
|
||||
|
||||
[features]
|
||||
default = ["rustcrypto-tls"]
|
||||
rustcrypto-tls = ["reqwest/rustls-tls-webpki-roots-no-provider", "dep:rustls-rustcrypto"]
|
||||
ring-tls = ["reqwest/rustls-tls-webpki-roots"]
|
||||
apidocs = ["dep:utoipa"]
|
||||
|
||||
[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"] }
|
||||
axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json"] }
|
||||
thiserror = "1.0.52"
|
||||
libc = "0.2.150"
|
||||
log = "0.4.20"
|
||||
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, features = ["io-util"] }
|
||||
futures = { version = "0.3.32", default-features = false, features = ["std"] }
|
||||
serde_json = "1.0.114"
|
||||
image = { version = "0.25.1", default-features = false, features = ["png", "gif"] }
|
||||
tempfile = "3.10.2"
|
||||
async_zip = { version = "0.0.17", features = ["tokio"] }
|
||||
anyhow = "1.0.98"
|
||||
reqwest = { version = "0.12.20", default-features = false }
|
||||
rustls-rustcrypto = { version = "0.0.2-alpha", optional = true }
|
||||
async-trait = "0.1.88"
|
||||
utoipa = { version = "5.4.0", optional = true }
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
@@ -1,5 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
use std::{future, pin};
|
||||
use std::{cmp, future, pin};
|
||||
|
||||
use axum::Json;
|
||||
use axum::{
|
||||
@@ -7,10 +7,9 @@ use axum::{
|
||||
http::StatusCode,
|
||||
};
|
||||
use futures::TryStreamExt;
|
||||
use log::{debug, error, info};
|
||||
use rayhunter::analysis::analyzer::Harness;
|
||||
use log::{error, info};
|
||||
use rayhunter::analysis::analyzer::{AnalyzerConfig, EventType, Harness};
|
||||
use rayhunter::diag::{DataType, MessagesContainer};
|
||||
use rayhunter::qmdl::QmdlReader;
|
||||
use serde::Serialize;
|
||||
use tokio::fs::File;
|
||||
use tokio::io::{AsyncWriteExt, BufWriter};
|
||||
@@ -20,12 +19,10 @@ use tokio_util::task::TaskTracker;
|
||||
|
||||
use crate::qmdl_store::RecordingStore;
|
||||
use crate::server::ServerState;
|
||||
use crate::dummy_analyzer::TestAnalyzer;
|
||||
|
||||
pub struct AnalysisWriter {
|
||||
writer: BufWriter<File>,
|
||||
harness: Harness,
|
||||
bytes_written: usize,
|
||||
}
|
||||
|
||||
// We write our analysis results to a file immediately to minimize the amount of
|
||||
@@ -35,15 +32,11 @@ pub struct AnalysisWriter {
|
||||
// lets us simply append new rows to the end without parsing the entire JSON
|
||||
// object beforehand.
|
||||
impl AnalysisWriter {
|
||||
pub async fn new(file: File, enable_dummy_analyzer: bool) -> Result<Self, std::io::Error> {
|
||||
let mut harness = Harness::new_with_all_analyzers();
|
||||
if enable_dummy_analyzer {
|
||||
harness.add_analyzer(Box::new(TestAnalyzer { count: 0 }));
|
||||
}
|
||||
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),
|
||||
bytes_written: 0,
|
||||
harness,
|
||||
};
|
||||
let metadata = result.harness.get_metadata();
|
||||
@@ -52,19 +45,25 @@ impl AnalysisWriter {
|
||||
}
|
||||
|
||||
// Runs the analysis harness on the given container, serializing the results
|
||||
// to the analysis file and returning the file's new length.
|
||||
pub async fn analyze(&mut self, container: MessagesContainer) -> Result<(usize, bool), std::io::Error> {
|
||||
let row = self.harness.analyze_qmdl_messages(container);
|
||||
if !row.is_empty() {
|
||||
self.write(&row).await?;
|
||||
// to the analysis file, returning the whether any warnings were detected
|
||||
pub async fn analyze(
|
||||
&mut self,
|
||||
container: MessagesContainer,
|
||||
) -> Result<EventType, std::io::Error> {
|
||||
let mut max_type = EventType::Informational;
|
||||
|
||||
for row in self.harness.analyze_qmdl_messages(container) {
|
||||
if !row.is_empty() {
|
||||
self.write(&row).await?;
|
||||
}
|
||||
max_type = cmp::max(max_type, row.get_max_event_type());
|
||||
}
|
||||
Ok((self.bytes_written, row.contains_warnings()))
|
||||
Ok(max_type)
|
||||
}
|
||||
|
||||
async fn write<T: Serialize>(&mut self, value: &T) -> Result<(), std::io::Error> {
|
||||
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(())
|
||||
@@ -77,14 +76,37 @@ impl AnalysisWriter {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone, Default)]
|
||||
/// The system status relating to QMDL file analysis
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
||||
pub struct AnalysisStatus {
|
||||
/// The vector array of queued files
|
||||
queued: Vec<String>,
|
||||
/// The file currently being analyzed
|
||||
running: Option<String>,
|
||||
/// The vector array of finished files
|
||||
finished: Vec<String>,
|
||||
}
|
||||
|
||||
impl AnalysisStatus {
|
||||
pub fn new(store: &RecordingStore) -> Self {
|
||||
let existing_recordings: Vec<String> = store
|
||||
.manifest
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| entry.name.clone())
|
||||
.collect();
|
||||
AnalysisStatus {
|
||||
queued: Vec::new(),
|
||||
running: None,
|
||||
finished: existing_recordings,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum AnalysisCtrlMessage {
|
||||
NewFilesQueued,
|
||||
RecordingFinished(String),
|
||||
Exit,
|
||||
}
|
||||
|
||||
@@ -100,70 +122,61 @@ async fn dequeue_to_running(analysis_status_lock: Arc<RwLock<AnalysisStatus>>) -
|
||||
name
|
||||
}
|
||||
|
||||
async fn clear_running(analysis_status_lock: Arc<RwLock<AnalysisStatus>>) {
|
||||
async fn finish_running_analysis(analysis_status_lock: Arc<RwLock<AnalysisStatus>>) {
|
||||
let mut analysis_status = analysis_status_lock.write().await;
|
||||
analysis_status.running = None;
|
||||
let finished = analysis_status.running.take().unwrap();
|
||||
analysis_status.finished.push(finished);
|
||||
}
|
||||
|
||||
async fn perform_analysis(
|
||||
name: &str,
|
||||
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
|
||||
enable_dummy_analyzer: bool,
|
||||
analyzer_config: &AnalyzerConfig,
|
||||
) -> Result<(), String> {
|
||||
info!("Opening QMDL and analysis file for {}...", name);
|
||||
let (analysis_file, qmdl_file, entry_index) = {
|
||||
info!("Opening QMDL and analysis file for {name}...");
|
||||
let (analysis_file, qmdl_reader) = {
|
||||
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))?;
|
||||
.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
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
let qmdl_reader = qmdl_store
|
||||
.open_entry_qmdl(entry_index)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))?;
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
|
||||
(analysis_file, qmdl_file, entry_index)
|
||||
(analysis_file, qmdl_reader)
|
||||
};
|
||||
|
||||
let mut analysis_writer = AnalysisWriter::new(analysis_file, enable_dummy_analyzer)
|
||||
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)));
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
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);
|
||||
info!("Starting analysis for {name}...");
|
||||
while let Some(container) = qmdl_stream
|
||||
.try_next()
|
||||
.await
|
||||
.expect("failed getting QMDL container")
|
||||
{
|
||||
let (size_bytes, _) = analysis_writer
|
||||
let _ = analysis_writer
|
||||
.analyze(container)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))?;
|
||||
debug!("{} analysis: {} bytes written", name, size_bytes);
|
||||
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||
qmdl_store
|
||||
.update_entry_analysis_size(entry_index, size_bytes)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))?;
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
}
|
||||
|
||||
analysis_writer
|
||||
.close()
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))?;
|
||||
info!("Analysis for {} complete!", name);
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
info!("Analysis for {name} complete!");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -173,7 +186,7 @@ pub fn run_analysis_thread(
|
||||
mut analysis_rx: Receiver<AnalysisCtrlMessage>,
|
||||
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
|
||||
analysis_status_lock: Arc<RwLock<AnalysisStatus>>,
|
||||
enable_dummy_analyzer: bool,
|
||||
analyzer_config: AnalyzerConfig,
|
||||
) {
|
||||
task_tracker.spawn(async move {
|
||||
loop {
|
||||
@@ -182,18 +195,34 @@ pub fn run_analysis_thread(
|
||||
let count = queued_len(analysis_status_lock.clone()).await;
|
||||
for _ in 0..count {
|
||||
let name = dequeue_to_running(analysis_status_lock.clone()).await;
|
||||
if let Err(err) = perform_analysis(&name, qmdl_store_lock.clone(), enable_dummy_analyzer).await {
|
||||
error!("failed to analyze {}: {}", name, err);
|
||||
if let Err(err) =
|
||||
perform_analysis(&name, qmdl_store_lock.clone(), &analyzer_config).await
|
||||
{
|
||||
error!("failed to analyze {name}: {err}");
|
||||
}
|
||||
clear_running(analysis_status_lock.clone()).await;
|
||||
finish_running_analysis(analysis_status_lock.clone()).await;
|
||||
}
|
||||
}
|
||||
Some(AnalysisCtrlMessage::RecordingFinished(name)) => {
|
||||
let mut status = analysis_status_lock.write().await;
|
||||
status.finished.push(name);
|
||||
}
|
||||
Some(AnalysisCtrlMessage::Exit) | None => return,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
get,
|
||||
path = "/api/analysis",
|
||||
tag = "Recordings",
|
||||
responses(
|
||||
(status = StatusCode::OK, description = "Success", body = AnalysisStatus)
|
||||
),
|
||||
summary = "Analysis status",
|
||||
description = "Show analysis status for all QMDL files."
|
||||
))]
|
||||
pub async fn get_analysis_status(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
) -> Result<Json<AnalysisStatus>, (StatusCode, String)> {
|
||||
@@ -210,6 +239,20 @@ fn queue_qmdl(name: &str, analysis_status: &mut RwLockWriteGuard<AnalysisStatus>
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
post,
|
||||
path = "/api/analysis/{name}",
|
||||
tag = "Recordings",
|
||||
responses(
|
||||
(status = StatusCode::ACCEPTED, description = "Success"),
|
||||
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Unable to queue analysis file")
|
||||
),
|
||||
params(
|
||||
("name" = String, Path, description = "QMDL file to analyze")
|
||||
),
|
||||
summary = "Start analysis",
|
||||
description = "Begin analysis of QMDL file {name}."
|
||||
))]
|
||||
pub async fn start_analysis(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
Path(qmdl_name): Path<String>,
|
||||
@@ -240,7 +283,7 @@ pub async fn start_analysis(
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("failed to queue new analysis files: {:?}", e),
|
||||
format!("failed to queue new analysis files: {e:?}"),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
121
daemon/src/battery/mod.rs
Normal file
121
daemon/src/battery/mod.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
use std::{path::Path, time::Duration};
|
||||
|
||||
use log::{info, warn};
|
||||
use rayhunter::Device;
|
||||
use serde::Serialize;
|
||||
use tokio::select;
|
||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||
|
||||
use crate::{
|
||||
error::RayhunterError,
|
||||
notifications::{Notification, NotificationType},
|
||||
};
|
||||
|
||||
pub mod orbic;
|
||||
pub mod tmobile;
|
||||
pub mod tplink;
|
||||
pub mod wingtech;
|
||||
|
||||
const LOW_BATTERY_LEVEL: u8 = 10;
|
||||
|
||||
/// Device battery information
|
||||
#[derive(Clone, Copy, PartialEq, Debug, Serialize)]
|
||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
||||
pub struct BatteryState {
|
||||
/// The current level in percentage of the device battery
|
||||
level: u8,
|
||||
/// A boolean indicating whether the battery is currently being charged
|
||||
is_plugged_in: bool,
|
||||
}
|
||||
|
||||
async fn is_plugged_in_from_file(path: &Path) -> Result<bool, RayhunterError> {
|
||||
match tokio::fs::read_to_string(path)
|
||||
.await
|
||||
.map_err(RayhunterError::TokioError)?
|
||||
.chars()
|
||||
.next()
|
||||
{
|
||||
Some('0') => Ok(false),
|
||||
Some('1') => Ok(true),
|
||||
_ => Err(RayhunterError::BatteryPluggedInStatusParseError),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_level_from_percentage_file(path: &Path) -> Result<u8, RayhunterError> {
|
||||
tokio::fs::read_to_string(path)
|
||||
.await
|
||||
.map_err(RayhunterError::TokioError)?
|
||||
.trim_end()
|
||||
.parse()
|
||||
.or(Err(RayhunterError::BatteryLevelParseError))
|
||||
}
|
||||
|
||||
pub async fn get_battery_status(device: &Device) -> Result<BatteryState, RayhunterError> {
|
||||
Ok(match device {
|
||||
Device::Orbic => orbic::get_battery_state().await?,
|
||||
Device::Wingtech => wingtech::get_battery_state().await?,
|
||||
Device::Tmobile => tmobile::get_battery_state().await?,
|
||||
Device::Tplink => tplink::get_battery_state().await?,
|
||||
_ => return Err(RayhunterError::FunctionNotSupportedForDeviceError),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn run_battery_notification_worker(
|
||||
task_tracker: &TaskTracker,
|
||||
device: Device,
|
||||
notification_channel: tokio::sync::mpsc::Sender<Notification>,
|
||||
shutdown_token: CancellationToken,
|
||||
) {
|
||||
task_tracker.spawn(async move {
|
||||
// Don't send a notification initially if the device starts at a low battery level.
|
||||
let mut triggered = match get_battery_status(&device).await {
|
||||
Err(RayhunterError::FunctionNotSupportedForDeviceError) => {
|
||||
info!("Battery status not supported for this device, disabling battery notifications");
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to get battery status: {e}");
|
||||
true
|
||||
}
|
||||
Ok(status) => status.level <= LOW_BATTERY_LEVEL,
|
||||
};
|
||||
|
||||
loop {
|
||||
select! {
|
||||
_ = shutdown_token.cancelled() => break,
|
||||
_ = tokio::time::sleep(Duration::from_secs(15)) => {}
|
||||
}
|
||||
|
||||
let status = match get_battery_status(&device).await {
|
||||
Err(RayhunterError::FunctionNotSupportedForDeviceError) => {
|
||||
info!("Battery status not supported for this device, disabling battery notifications");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to get battery status: {e}");
|
||||
continue;
|
||||
}
|
||||
Ok(status) => status,
|
||||
};
|
||||
|
||||
// To avoid flapping, if the notification has already been triggered
|
||||
// wait until the device has been plugged in and the battery level
|
||||
// is high enough to re-enable notifications.
|
||||
if triggered && status.is_plugged_in && status.level > LOW_BATTERY_LEVEL {
|
||||
triggered = false;
|
||||
continue;
|
||||
}
|
||||
if !triggered && !status.is_plugged_in && status.level <= LOW_BATTERY_LEVEL {
|
||||
notification_channel
|
||||
.send(Notification::new(
|
||||
NotificationType::LowBattery,
|
||||
"Rayhunter's battery is low".to_string(),
|
||||
None,
|
||||
))
|
||||
.await
|
||||
.expect("Failed to send to notification channel");
|
||||
triggered = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
28
daemon/src/battery/orbic.rs
Normal file
28
daemon/src/battery/orbic.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
use std::path::Path;
|
||||
|
||||
use crate::{
|
||||
battery::{BatteryState, is_plugged_in_from_file},
|
||||
error::RayhunterError,
|
||||
};
|
||||
|
||||
const BATTERY_LEVEL_FILE: &str = "/sys/kernel/chg_info/level";
|
||||
const PLUGGED_IN_STATE_FILE: &str = "/sys/kernel/chg_info/chg_en";
|
||||
|
||||
pub async fn get_battery_state() -> Result<BatteryState, RayhunterError> {
|
||||
Ok(BatteryState {
|
||||
level: match tokio::fs::read_to_string(&BATTERY_LEVEL_FILE)
|
||||
.await
|
||||
.map_err(RayhunterError::TokioError)?
|
||||
.chars()
|
||||
.next()
|
||||
{
|
||||
Some('1') => Ok(10),
|
||||
Some('2') => Ok(25),
|
||||
Some('3') => Ok(50),
|
||||
Some('4') => Ok(75),
|
||||
Some('5') => Ok(100),
|
||||
_ => Err(RayhunterError::BatteryLevelParseError),
|
||||
}?,
|
||||
is_plugged_in: is_plugged_in_from_file(Path::new(PLUGGED_IN_STATE_FILE)).await?,
|
||||
})
|
||||
}
|
||||
16
daemon/src/battery/tmobile.rs
Normal file
16
daemon/src/battery/tmobile.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use std::path::Path;
|
||||
|
||||
use crate::{
|
||||
battery::{BatteryState, get_level_from_percentage_file, is_plugged_in_from_file},
|
||||
error::RayhunterError,
|
||||
};
|
||||
|
||||
const BATTERY_LEVEL_FILE: &str = "/sys/class/power_supply/bms/capacity";
|
||||
const PLUGGED_IN_STATE_FILE: &str = "/sys/devices/78d9000.usb/power_supply/usb/online";
|
||||
|
||||
pub async fn get_battery_state() -> Result<BatteryState, RayhunterError> {
|
||||
Ok(BatteryState {
|
||||
level: get_level_from_percentage_file(Path::new(BATTERY_LEVEL_FILE)).await?,
|
||||
is_plugged_in: is_plugged_in_from_file(Path::new(PLUGGED_IN_STATE_FILE)).await?,
|
||||
})
|
||||
}
|
||||
39
daemon/src/battery/tplink.rs
Normal file
39
daemon/src/battery/tplink.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use crate::{battery::BatteryState, error::RayhunterError};
|
||||
|
||||
pub async fn get_battery_state() -> Result<BatteryState, RayhunterError> {
|
||||
let uci_battery = tokio::process::Command::new("uci")
|
||||
.arg("get")
|
||||
.arg("battery.battery_mgr.power_level")
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
let uci_plugged_in = tokio::process::Command::new("uci")
|
||||
.arg("get")
|
||||
.arg("battery.battery_mgr.is_charging")
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
if !uci_battery.status.success() {
|
||||
return Err(RayhunterError::BatteryLevelParseError);
|
||||
}
|
||||
|
||||
if !uci_plugged_in.status.success() {
|
||||
return Err(RayhunterError::BatteryPluggedInStatusParseError);
|
||||
}
|
||||
|
||||
let uci_battery = String::from_utf8_lossy(&uci_battery.stdout)
|
||||
.trim_end()
|
||||
.parse()
|
||||
.map_err(|_| RayhunterError::BatteryLevelParseError)?;
|
||||
|
||||
let uci_plugged_in = match String::from_utf8_lossy(&uci_plugged_in.stdout).trim_end() {
|
||||
"0" => Ok(false),
|
||||
"1" => Ok(true),
|
||||
_ => Err(RayhunterError::BatteryPluggedInStatusParseError),
|
||||
}?;
|
||||
|
||||
Ok(BatteryState {
|
||||
level: uci_battery,
|
||||
is_plugged_in: uci_plugged_in,
|
||||
})
|
||||
}
|
||||
17
daemon/src/battery/wingtech.rs
Normal file
17
daemon/src/battery/wingtech.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use std::path::Path;
|
||||
|
||||
use crate::{
|
||||
battery::{BatteryState, get_level_from_percentage_file, is_plugged_in_from_file},
|
||||
error::RayhunterError,
|
||||
};
|
||||
|
||||
const BATTERY_LEVEL_FILE: &str =
|
||||
"/sys/devices/78b7000.i2c/i2c-3/3-0063/power_supply/cw2017-bat/capacity";
|
||||
const PLUGGED_IN_STATE_FILE: &str = "/sys/devices/8a00000.ssusb/power_supply/usb/online";
|
||||
|
||||
pub async fn get_battery_state() -> Result<BatteryState, RayhunterError> {
|
||||
Ok(BatteryState {
|
||||
level: get_level_from_percentage_file(Path::new(BATTERY_LEVEL_FILE)).await?,
|
||||
is_plugged_in: is_plugged_in_from_file(Path::new(PLUGGED_IN_STATE_FILE)).await?,
|
||||
})
|
||||
}
|
||||
12
daemon/src/bin/gen_api.rs
Normal file
12
daemon/src/bin/gen_api.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use std::{env, fs};
|
||||
|
||||
fn main() {
|
||||
let content = rayhunter_daemon::ApiDocs::generate();
|
||||
let mut filename = "openapi.json".to_string();
|
||||
let args: Vec<String> = env::args().collect();
|
||||
if args.len() > 1 {
|
||||
filename = args[1].to_string();
|
||||
}
|
||||
|
||||
fs::write(filename, content).unwrap();
|
||||
}
|
||||
83
daemon/src/config.rs
Normal file
83
daemon/src/config.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
use log::warn;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use rayhunter::Device;
|
||||
use rayhunter::analysis::analyzer::AnalyzerConfig;
|
||||
|
||||
use crate::error::RayhunterError;
|
||||
use crate::notifications::NotificationType;
|
||||
|
||||
/// The structure of a valid rayhunter configuration
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
||||
pub struct Config {
|
||||
/// Path to store QMDL files
|
||||
pub qmdl_store_path: String,
|
||||
/// Listening port
|
||||
pub port: u16,
|
||||
/// Debug mode
|
||||
pub debug_mode: bool,
|
||||
/// Internal device name
|
||||
pub device: Device,
|
||||
/// UI level
|
||||
pub ui_level: u8,
|
||||
/// Colorblind mode
|
||||
pub colorblind_mode: bool,
|
||||
/// Key input mode
|
||||
pub key_input_mode: u8,
|
||||
/// ntfy.sh URL
|
||||
pub ntfy_url: Option<String>,
|
||||
/// Vector containing the types of enabled notifications
|
||||
pub enabled_notifications: Vec<NotificationType>,
|
||||
/// Vector containing the list of enabled analyzers
|
||||
pub analyzers: AnalyzerConfig,
|
||||
pub min_space_to_start_recording_mb: u64,
|
||||
pub min_space_to_continue_recording_mb: u64,
|
||||
}
|
||||
|
||||
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(),
|
||||
ntfy_url: None,
|
||||
enabled_notifications: vec![NotificationType::Warning, NotificationType::LowBattery],
|
||||
min_space_to_start_recording_mb: 1,
|
||||
min_space_to_continue_recording_mb: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
680
daemon/src/diag.rs
Normal file
680
daemon/src/diag.rs
Normal file
@@ -0,0 +1,680 @@
|
||||
use std::ops::DerefMut;
|
||||
use std::pin::pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::http::header::CONTENT_TYPE;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use futures::{StreamExt, TryStreamExt, future};
|
||||
use log::{debug, error, info, warn};
|
||||
use tokio::fs::File;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::sync::mpsc::{Receiver, Sender};
|
||||
use tokio::sync::{RwLock, oneshot};
|
||||
use tokio_stream::wrappers::LinesStream;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
#[cfg(feature = "apidocs")]
|
||||
use rayhunter::analysis::analyzer::ReportMetadata;
|
||||
use rayhunter::analysis::analyzer::{AnalysisLineNormalizer, AnalyzerConfig, EventType};
|
||||
use rayhunter::diag::{DataType, MessagesContainer};
|
||||
use rayhunter::diag_device::DiagDevice;
|
||||
use rayhunter::qmdl::QmdlWriter;
|
||||
|
||||
use crate::analysis::{AnalysisCtrlMessage, AnalysisWriter};
|
||||
use crate::display;
|
||||
use crate::notifications::{Notification, NotificationType};
|
||||
use crate::qmdl_store::{RecordingStore, RecordingStoreError};
|
||||
use crate::server::ServerState;
|
||||
use crate::stats::DiskStats;
|
||||
|
||||
const DISK_CHECK_BYTES_INTERVAL: usize = 256 * 1024;
|
||||
|
||||
pub enum DiagDeviceCtrlMessage {
|
||||
StopRecording,
|
||||
StartRecording {
|
||||
response_tx: Option<oneshot::Sender<Result<(), String>>>,
|
||||
},
|
||||
DeleteEntry {
|
||||
name: String,
|
||||
response_tx: oneshot::Sender<Result<(), RecordingStoreError>>,
|
||||
},
|
||||
DeleteAllEntries {
|
||||
response_tx: oneshot::Sender<Result<(), RecordingStoreError>>,
|
||||
},
|
||||
Exit,
|
||||
}
|
||||
|
||||
pub struct DiagTask {
|
||||
ui_update_sender: Sender<display::DisplayState>,
|
||||
analysis_sender: Sender<AnalysisCtrlMessage>,
|
||||
analyzer_config: AnalyzerConfig,
|
||||
notification_channel: tokio::sync::mpsc::Sender<Notification>,
|
||||
min_space_to_start_mb: u64,
|
||||
min_space_to_continue_mb: u64,
|
||||
state: DiagState,
|
||||
max_type_seen: EventType,
|
||||
bytes_since_space_check: usize,
|
||||
low_space_warned: bool,
|
||||
}
|
||||
|
||||
enum DiagState {
|
||||
Recording {
|
||||
qmdl_writer: Box<QmdlWriter<File>>,
|
||||
analysis_writer: Box<AnalysisWriter>,
|
||||
},
|
||||
Stopped,
|
||||
}
|
||||
|
||||
enum DiskSpaceCheck {
|
||||
Ok(u64),
|
||||
Warning(u64),
|
||||
Critical(u64),
|
||||
Failed,
|
||||
}
|
||||
|
||||
fn check_disk_space(path: &std::path::Path, warning_mb: u64, critical_mb: u64) -> DiskSpaceCheck {
|
||||
match DiskStats::new(path.to_str().unwrap()) {
|
||||
Ok(stats) => {
|
||||
let available_mb = stats.available_bytes.unwrap_or(0) / 1024 / 1024;
|
||||
if available_mb < critical_mb {
|
||||
DiskSpaceCheck::Critical(available_mb)
|
||||
} else if available_mb < warning_mb {
|
||||
DiskSpaceCheck::Warning(available_mb)
|
||||
} else {
|
||||
DiskSpaceCheck::Ok(available_mb)
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to check disk space: {e}");
|
||||
DiskSpaceCheck::Failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DiagTask {
|
||||
fn new(
|
||||
ui_update_sender: Sender<display::DisplayState>,
|
||||
analysis_sender: Sender<AnalysisCtrlMessage>,
|
||||
analyzer_config: AnalyzerConfig,
|
||||
notification_channel: tokio::sync::mpsc::Sender<Notification>,
|
||||
min_space_to_start_mb: u64,
|
||||
min_space_to_continue_mb: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
ui_update_sender,
|
||||
analysis_sender,
|
||||
analyzer_config,
|
||||
notification_channel,
|
||||
min_space_to_start_mb,
|
||||
min_space_to_continue_mb,
|
||||
state: DiagState::Stopped,
|
||||
max_type_seen: EventType::Informational,
|
||||
bytes_since_space_check: 0,
|
||||
low_space_warned: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Start recording, returning an error if disk space is too low.
|
||||
async fn start(&mut self, qmdl_store: &mut RecordingStore) -> Result<(), String> {
|
||||
self.max_type_seen = EventType::Informational;
|
||||
self.bytes_since_space_check = 0;
|
||||
self.low_space_warned = false;
|
||||
|
||||
match check_disk_space(
|
||||
&qmdl_store.path,
|
||||
self.min_space_to_start_mb,
|
||||
self.min_space_to_continue_mb,
|
||||
) {
|
||||
DiskSpaceCheck::Critical(mb) | DiskSpaceCheck::Warning(mb) => {
|
||||
let msg = format!(
|
||||
"Insufficient disk space: {}MB available, {}MB required",
|
||||
mb, self.min_space_to_start_mb
|
||||
);
|
||||
error!("{msg}");
|
||||
return Err(msg);
|
||||
}
|
||||
DiskSpaceCheck::Ok(mb) => {
|
||||
info!("Starting recording with {}MB disk space available", mb);
|
||||
}
|
||||
DiskSpaceCheck::Failed => {}
|
||||
}
|
||||
|
||||
let (qmdl_gz_file, analysis_file) = match qmdl_store.new_entry().await {
|
||||
Ok(files) => files,
|
||||
Err(e) => {
|
||||
let msg = format!("failed creating QMDL file entry: {e}");
|
||||
error!("{msg}");
|
||||
return Err(msg);
|
||||
}
|
||||
};
|
||||
self.stop_current_recording().await;
|
||||
let qmdl_writer = Box::new(QmdlWriter::new(qmdl_gz_file));
|
||||
let analysis_writer = match AnalysisWriter::new(analysis_file, &self.analyzer_config).await
|
||||
{
|
||||
Ok(writer) => Box::new(writer),
|
||||
Err(e) => {
|
||||
let msg = format!("failed to create analysis writer: {e}");
|
||||
error!("{msg}");
|
||||
return Err(msg);
|
||||
}
|
||||
};
|
||||
self.state = DiagState::Recording {
|
||||
qmdl_writer,
|
||||
analysis_writer,
|
||||
};
|
||||
if let Err(e) = self
|
||||
.ui_update_sender
|
||||
.send(display::DisplayState::Recording)
|
||||
.await
|
||||
{
|
||||
warn!("couldn't send ui update message: {e}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop recording, optionally annotating the entry with a reason.
|
||||
async fn stop(&mut self, qmdl_store: &mut RecordingStore, reason: Option<String>) {
|
||||
self.stop_current_recording().await;
|
||||
if let Some(reason) = reason
|
||||
&& let Err(e) = qmdl_store.set_current_stop_reason(reason).await
|
||||
{
|
||||
warn!("couldn't set stop reason: {e}");
|
||||
}
|
||||
if let Some((_, entry)) = qmdl_store.get_current_entry()
|
||||
&& let Err(e) = self
|
||||
.analysis_sender
|
||||
.send(AnalysisCtrlMessage::RecordingFinished(
|
||||
entry.name.to_string(),
|
||||
))
|
||||
.await
|
||||
{
|
||||
warn!("couldn't send analysis message: {e}");
|
||||
}
|
||||
if let Err(e) = qmdl_store.close_current_entry().await {
|
||||
error!("couldn't close current entry: {e}");
|
||||
}
|
||||
if let Err(e) = self
|
||||
.ui_update_sender
|
||||
.send(display::DisplayState::Paused)
|
||||
.await
|
||||
{
|
||||
warn!("couldn't send ui update message: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_entry(
|
||||
&mut self,
|
||||
qmdl_store: &mut RecordingStore,
|
||||
name: &str,
|
||||
) -> Result<(), RecordingStoreError> {
|
||||
if qmdl_store.is_current_entry(name) {
|
||||
self.stop(qmdl_store, None).await;
|
||||
}
|
||||
let res = qmdl_store.delete_entry(name).await;
|
||||
if let Err(e) = res.as_ref() {
|
||||
error!("Error deleting QMDL entry {e}");
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
async fn delete_all_entries(
|
||||
&mut self,
|
||||
qmdl_store: &mut RecordingStore,
|
||||
) -> Result<(), RecordingStoreError> {
|
||||
self.stop(qmdl_store, None).await;
|
||||
let res = qmdl_store.delete_all_entries().await;
|
||||
if let Err(e) = res.as_ref() {
|
||||
error!("Error deleting QMDL entries {e}");
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
async fn stop_current_recording(&mut self) {
|
||||
let mut state = DiagState::Stopped;
|
||||
std::mem::swap(&mut self.state, &mut state);
|
||||
if let DiagState::Recording {
|
||||
qmdl_writer,
|
||||
analysis_writer,
|
||||
..
|
||||
} = state
|
||||
{
|
||||
match (qmdl_writer.close().await, analysis_writer.close().await) {
|
||||
(Ok(()), Ok(())) => {}
|
||||
(qmdl_result, analysis_result) => {
|
||||
if let Err(err) = qmdl_result {
|
||||
error!("failed to close QmdlWriter: {:?}", err);
|
||||
}
|
||||
if let Err(err) = analysis_result {
|
||||
error!("failed to close AnalysisWriter: {:?}", err);
|
||||
}
|
||||
panic!();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn process_container(
|
||||
&mut self,
|
||||
qmdl_store: &mut RecordingStore,
|
||||
container: MessagesContainer,
|
||||
) {
|
||||
if container.data_type != DataType::UserSpace {
|
||||
debug!("skipping non-userspace diag messages...");
|
||||
return;
|
||||
}
|
||||
// keep track of how many bytes were written to the QMDL file so we can read
|
||||
// a valid block of data from it in the HTTP server
|
||||
if let DiagState::Recording {
|
||||
qmdl_writer,
|
||||
analysis_writer,
|
||||
} = &mut self.state
|
||||
{
|
||||
if self.bytes_since_space_check >= DISK_CHECK_BYTES_INTERVAL {
|
||||
self.bytes_since_space_check = 0;
|
||||
match check_disk_space(
|
||||
&qmdl_store.path,
|
||||
self.min_space_to_start_mb,
|
||||
self.min_space_to_continue_mb,
|
||||
) {
|
||||
DiskSpaceCheck::Critical(mb) => {
|
||||
let reason = format!(
|
||||
"Disk space critically low ({}MB free), recording stopped automatically",
|
||||
mb
|
||||
);
|
||||
error!("{reason}");
|
||||
|
||||
self.notification_channel
|
||||
.send(Notification::new(
|
||||
NotificationType::Warning,
|
||||
reason.clone(),
|
||||
None,
|
||||
))
|
||||
.await
|
||||
.ok();
|
||||
|
||||
self.stop(qmdl_store, Some(reason)).await;
|
||||
return;
|
||||
}
|
||||
DiskSpaceCheck::Warning(mb) => {
|
||||
if !self.low_space_warned {
|
||||
self.low_space_warned = true;
|
||||
warn!("Disk space low: {}MB remaining", mb);
|
||||
self.notification_channel
|
||||
.send(Notification::new(
|
||||
NotificationType::Warning,
|
||||
format!("Disk space low: {}MB free", mb),
|
||||
Some(Duration::from_secs(30)),
|
||||
))
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = qmdl_writer.write_container(&container).await {
|
||||
let reason = format!("failed to write to QMDL (disk full?): {e}");
|
||||
error!("{reason}");
|
||||
self.stop(qmdl_store, Some(reason)).await;
|
||||
return;
|
||||
}
|
||||
debug!(
|
||||
"total QMDL bytes written: {}, updating manifest...",
|
||||
qmdl_writer.total_uncompressed_bytes
|
||||
);
|
||||
let index = qmdl_store
|
||||
.current_entry
|
||||
.expect("DiagDevice had qmdl_writer, but QmdlStore didn't have current entry???");
|
||||
if let Err(e) = qmdl_store
|
||||
.update_entry_qmdl_size(index, qmdl_writer.total_uncompressed_bytes)
|
||||
.await
|
||||
{
|
||||
let reason = format!("failed to update manifest (disk full?): {e}");
|
||||
error!("{reason}");
|
||||
self.stop(qmdl_store, Some(reason)).await;
|
||||
return;
|
||||
}
|
||||
debug!("done!");
|
||||
let container_bytes: usize = container.messages.iter().map(|m| m.data.len()).sum();
|
||||
self.bytes_since_space_check += container_bytes;
|
||||
let max_type = match analysis_writer.analyze(container).await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
warn!("failed to analyze container: {e}");
|
||||
EventType::Informational
|
||||
}
|
||||
};
|
||||
|
||||
if max_type > EventType::Informational {
|
||||
info!("a heuristic triggered on this run!");
|
||||
self.notification_channel
|
||||
.send(Notification::new(
|
||||
NotificationType::Warning,
|
||||
format!("Rayhunter has detected a {:?} severity event", max_type),
|
||||
Some(Duration::from_secs(60 * 5)),
|
||||
))
|
||||
.await
|
||||
.expect("Failed to send to notification channel");
|
||||
}
|
||||
|
||||
if max_type > self.max_type_seen {
|
||||
self.max_type_seen = max_type;
|
||||
if self.max_type_seen > EventType::Informational {
|
||||
self.ui_update_sender
|
||||
.send(display::DisplayState::WarningDetected {
|
||||
event_type: self.max_type_seen,
|
||||
})
|
||||
.await
|
||||
.expect("couldn't send ui update message: {}");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debug!("no qmdl_writer set, continuing...");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn run_diag_read_thread(
|
||||
task_tracker: &TaskTracker,
|
||||
mut dev: DiagDevice,
|
||||
mut qmdl_file_rx: Receiver<DiagDeviceCtrlMessage>,
|
||||
qmdl_file_tx: Sender<DiagDeviceCtrlMessage>,
|
||||
ui_update_sender: Sender<display::DisplayState>,
|
||||
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
|
||||
analysis_sender: Sender<AnalysisCtrlMessage>,
|
||||
analyzer_config: AnalyzerConfig,
|
||||
notification_channel: tokio::sync::mpsc::Sender<Notification>,
|
||||
min_space_to_start_mb: u64,
|
||||
min_space_to_continue_mb: u64,
|
||||
) {
|
||||
task_tracker.spawn(async move {
|
||||
let mut diag_stream = pin!(dev.as_stream().into_stream());
|
||||
let mut diag_task = DiagTask::new(ui_update_sender, analysis_sender, analyzer_config, notification_channel, min_space_to_start_mb, min_space_to_continue_mb);
|
||||
qmdl_file_tx
|
||||
.send(DiagDeviceCtrlMessage::StartRecording { response_tx: None })
|
||||
.await
|
||||
.unwrap();
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg = qmdl_file_rx.recv() => {
|
||||
match msg {
|
||||
Some(DiagDeviceCtrlMessage::StartRecording { response_tx }) => {
|
||||
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||
let result = diag_task.start(qmdl_store.deref_mut()).await;
|
||||
if let Some(tx) = response_tx {
|
||||
tx.send(result).ok();
|
||||
}
|
||||
},
|
||||
Some(DiagDeviceCtrlMessage::StopRecording) => {
|
||||
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||
diag_task.stop(qmdl_store.deref_mut(), None).await;
|
||||
},
|
||||
// None means all the Senders have been dropped, so it's
|
||||
// time to go
|
||||
Some(DiagDeviceCtrlMessage::Exit) | None => {
|
||||
info!("Diag reader thread exiting...");
|
||||
diag_task.stop_current_recording().await;
|
||||
return Ok(())
|
||||
},
|
||||
Some(DiagDeviceCtrlMessage::DeleteEntry { name, response_tx }) => {
|
||||
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||
let resp = diag_task.delete_entry(qmdl_store.deref_mut(), name.as_str()).await;
|
||||
if response_tx.send(resp).is_err() {
|
||||
error!("Failed to send delete entry respons, receiver dropped");
|
||||
}
|
||||
},
|
||||
Some(DiagDeviceCtrlMessage::DeleteAllEntries { response_tx }) => {
|
||||
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||
let resp = diag_task.delete_all_entries(qmdl_store.deref_mut()).await;
|
||||
if response_tx.send(resp).is_err() {
|
||||
error!("Failed to send delete all entries respons, receiver dropped");
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
maybe_container = diag_stream.next() => {
|
||||
match maybe_container.unwrap() {
|
||||
Ok(container) => {
|
||||
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||
diag_task.process_container(qmdl_store.deref_mut(), container).await
|
||||
},
|
||||
Err(err) => {
|
||||
error!("error reading diag device: {err}");
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Start recording API for web thread
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
post,
|
||||
path = "/api/start-recording",
|
||||
tag = "Recordings",
|
||||
responses(
|
||||
(status = StatusCode::ACCEPTED, description = "Success"),
|
||||
(status = StatusCode::FORBIDDEN, description = "System is in debug mode"),
|
||||
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Recording action unsuccessful")
|
||||
),
|
||||
summary = "Start recording",
|
||||
description = "Begin a new data capture."
|
||||
))]
|
||||
pub async fn start_recording(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||
if state.config.debug_mode {
|
||||
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
|
||||
}
|
||||
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
state
|
||||
.diag_device_ctrl_sender
|
||||
.send(DiagDeviceCtrlMessage::StartRecording {
|
||||
response_tx: Some(response_tx),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("couldn't send start recording message: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
match response_rx.await {
|
||||
Ok(Ok(())) => Ok((StatusCode::ACCEPTED, "ok".to_string())),
|
||||
Ok(Err(reason)) => Err((StatusCode::INSUFFICIENT_STORAGE, reason)),
|
||||
Err(e) => Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("failed to receive start recording response: {e}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop recording API for web thread
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
post,
|
||||
path = "/api/stop-recording",
|
||||
tag = "Recordings",
|
||||
responses(
|
||||
(status = StatusCode::ACCEPTED, description = "Success"),
|
||||
(status = StatusCode::FORBIDDEN, description = "System is in debug mode"),
|
||||
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Recording action unsuccessful")
|
||||
),
|
||||
summary = "Stop recording",
|
||||
description = "Stop current data capture."
|
||||
))]
|
||||
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()))
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
post,
|
||||
path = "/api/delete-recording/{name}",
|
||||
tag = "Recordings",
|
||||
responses(
|
||||
(status = StatusCode::ACCEPTED, description = "Success"),
|
||||
(status = StatusCode::FORBIDDEN, description = "System is in debug mode"),
|
||||
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Delete action unsuccessful"),
|
||||
(status = StatusCode::BAD_REQUEST, description = "Bad recording name or no such recording")
|
||||
),
|
||||
params(
|
||||
("name" = String, Path, description = "QMDL file to delete")
|
||||
),
|
||||
summary = "Delete recording",
|
||||
description = "Remove data capture file named {name}."
|
||||
))]
|
||||
pub async fn delete_recording(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
Path(qmdl_name): Path<String>,
|
||||
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||
if state.config.debug_mode {
|
||||
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
|
||||
}
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
state
|
||||
.diag_device_ctrl_sender
|
||||
.send(DiagDeviceCtrlMessage::DeleteEntry {
|
||||
name: qmdl_name.clone(),
|
||||
response_tx,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("couldn't send delete entry message: {e}"),
|
||||
)
|
||||
})?;
|
||||
match response_rx.await.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("failed to receive delete response: {e}"),
|
||||
)
|
||||
})? {
|
||||
Ok(_) => Ok((StatusCode::ACCEPTED, "ok".to_string())),
|
||||
Err(RecordingStoreError::NoSuchEntryError) => Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("no recording with name {qmdl_name}"),
|
||||
)),
|
||||
Err(e) => Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("couldn't delete recording: {e}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
post,
|
||||
path = "/api/delete-all-recordings",
|
||||
tag = "Recordings",
|
||||
responses(
|
||||
(status = StatusCode::ACCEPTED, description = "Success"),
|
||||
(status = StatusCode::FORBIDDEN, description = "System is in debug mode"),
|
||||
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Delete action unsuccessful")
|
||||
),
|
||||
summary = "Delete all recordings",
|
||||
description = "Remove all saved data capture files."
|
||||
))]
|
||||
pub async fn delete_all_recordings(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||
if state.config.debug_mode {
|
||||
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
|
||||
}
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
state
|
||||
.diag_device_ctrl_sender
|
||||
.send(DiagDeviceCtrlMessage::DeleteAllEntries { response_tx })
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("couldn't send delete all entries message: {e}"),
|
||||
)
|
||||
})?;
|
||||
match response_rx.await.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("failed to receive delete all response: {e}"),
|
||||
)
|
||||
})? {
|
||||
Ok(_) => Ok((StatusCode::ACCEPTED, "ok".to_string())),
|
||||
Err(e) => Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("couldn't delete recordings: {e}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
get,
|
||||
path = "/api/analysis-report/{name}",
|
||||
tag = "Recordings",
|
||||
responses(
|
||||
(status = StatusCode::OK, description = "Success", body = ReportMetadata, content_type = "application/x-ndjson"),
|
||||
(status = StatusCode::SERVICE_UNAVAILABLE, description = "No QMDL files available; start a new recording."),
|
||||
(status = StatusCode::NOT_FOUND, description = "File {name} not found")
|
||||
),
|
||||
params(
|
||||
("name" = String, Path, description = "QMDL file to analyze")
|
||||
),
|
||||
summary = "Analysis report",
|
||||
description = "Download processed analysis report for QMDL file {name}, as well as the types (and versions) of analyzers used."
|
||||
))]
|
||||
pub async fn get_analysis_report(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
Path(qmdl_name): Path<String>,
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
let qmdl_store = state.qmdl_store_lock.read().await;
|
||||
let (entry_index, _) = if qmdl_name == "live" {
|
||||
qmdl_store.get_current_entry().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"No QMDL data's being recorded to analyze, try starting a new recording!".to_string(),
|
||||
))?
|
||||
} else {
|
||||
qmdl_store.entry_for_name(&qmdl_name).ok_or((
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("Couldn't find QMDL entry with name \"{qmdl_name}\""),
|
||||
))?
|
||||
};
|
||||
let analysis_file = qmdl_store
|
||||
.open_entry_analysis(entry_index)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?;
|
||||
|
||||
// Read and normalize the NDJSON file
|
||||
let reader = BufReader::new(analysis_file);
|
||||
let lines_stream = LinesStream::new(reader.lines());
|
||||
|
||||
let mut normalizer = AnalysisLineNormalizer::new();
|
||||
let normalized_stream = lines_stream
|
||||
.try_filter(|line| future::ready(!line.is_empty()))
|
||||
.map_ok(move |line| normalizer.normalize_line(line));
|
||||
|
||||
let headers = [(CONTENT_TYPE, "application/x-ndjson")];
|
||||
let body = Body::from_stream(normalized_stream);
|
||||
Ok((headers, body).into_response())
|
||||
}
|
||||
242
daemon/src/display/generic_framebuffer.rs
Normal file
242
daemon/src/display/generic_framebuffer.rs
Normal file
@@ -0,0 +1,242 @@
|
||||
use async_trait::async_trait;
|
||||
use image::{AnimationDecoder, DynamicImage, codecs::gif::GifDecoder, imageops::FilterType};
|
||||
use std::io::Cursor;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config;
|
||||
use crate::display::DisplayState;
|
||||
use rayhunter::analysis::analyzer::EventType;
|
||||
|
||||
use log::{error, info};
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||
|
||||
use include_dir::{Dir, include_dir};
|
||||
|
||||
const REFRESH_RATE: u64 = 1000; //how often in milliseconds to refresh the display
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct Dimensions {
|
||||
pub height: u32,
|
||||
pub width: u32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub enum LinePattern {
|
||||
Solid,
|
||||
Dashed, // _ _ _ _
|
||||
Dotted, // . . . .
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Copy, Clone)]
|
||||
pub enum Color {
|
||||
Red,
|
||||
Green,
|
||||
Blue,
|
||||
White,
|
||||
Black,
|
||||
Cyan,
|
||||
Yellow,
|
||||
Pink,
|
||||
Orange,
|
||||
}
|
||||
|
||||
impl Color {
|
||||
fn rgb(self) -> (u8, u8, u8) {
|
||||
match self {
|
||||
Color::Red => (0xff, 0, 0),
|
||||
Color::Green => (0, 0xff, 0),
|
||||
Color::Blue => (0, 0, 0xff),
|
||||
Color::White => (0xff, 0xff, 0xff),
|
||||
Color::Black => (0, 0, 0),
|
||||
Color::Cyan => (0, 0xff, 0xff),
|
||||
Color::Yellow => (0xff, 0xff, 0),
|
||||
Color::Pink => (0xfe, 0x24, 0xff),
|
||||
Color::Orange => (0xff, 0xa5, 0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn display_style_from_state(state: DisplayState, colorblind_mode: bool) -> (Color, LinePattern) {
|
||||
match state {
|
||||
DisplayState::Paused => (Color::White, LinePattern::Solid),
|
||||
DisplayState::Recording => {
|
||||
if colorblind_mode {
|
||||
(Color::Blue, LinePattern::Solid)
|
||||
} else {
|
||||
(Color::Green, LinePattern::Solid)
|
||||
}
|
||||
}
|
||||
DisplayState::WarningDetected { event_type } => match event_type {
|
||||
EventType::Informational => {
|
||||
if colorblind_mode {
|
||||
(Color::Blue, LinePattern::Solid)
|
||||
} else {
|
||||
(Color::Green, LinePattern::Solid)
|
||||
}
|
||||
}
|
||||
EventType::Low => (Color::Yellow, LinePattern::Dotted),
|
||||
EventType::Medium => (Color::Orange, LinePattern::Dashed),
|
||||
EventType::High => (Color::Red, LinePattern::Solid),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait GenericFramebuffer: Send + 'static {
|
||||
fn dimensions(&self) -> Dimensions;
|
||||
|
||||
async fn write_buffer(&mut self, buffer: Vec<(u8, u8, u8)>); // rgb, row-wise, left-to-right, top-to-bottom
|
||||
|
||||
async fn write_dynamic_image(&mut self, img: DynamicImage) {
|
||||
let dimensions = self.dimensions();
|
||||
let mut width = img.width();
|
||||
let mut height = img.height();
|
||||
let resized_img: DynamicImage;
|
||||
if height > dimensions.height || width > dimensions.width {
|
||||
resized_img = img.resize(dimensions.width, dimensions.height, FilterType::CatmullRom);
|
||||
width = dimensions.width.min(resized_img.width());
|
||||
height = dimensions.height.min(resized_img.height());
|
||||
} else {
|
||||
resized_img = img;
|
||||
}
|
||||
let img_rgba8 = resized_img.as_rgba8().unwrap();
|
||||
let mut buf = Vec::with_capacity((height * width).try_into().unwrap());
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let px = img_rgba8.get_pixel(x, y);
|
||||
buf.push((px[0], px[1], px[2]));
|
||||
}
|
||||
}
|
||||
|
||||
self.write_buffer(buf).await
|
||||
}
|
||||
|
||||
async fn draw_gif(&mut self, img_buffer: &[u8]) {
|
||||
let cursor = Cursor::new(img_buffer);
|
||||
if let Ok(decoder) = GifDecoder::new(cursor) {
|
||||
let frames: Vec<_> = decoder
|
||||
.into_frames()
|
||||
.filter_map(|f| f.ok())
|
||||
.map(|frame| {
|
||||
let (numerator, _) = frame.delay().numer_denom_ms();
|
||||
let img = DynamicImage::from(frame.into_buffer());
|
||||
(img, numerator as u64)
|
||||
})
|
||||
.collect();
|
||||
|
||||
for (img, delay_ms) in frames {
|
||||
self.write_dynamic_image(img).await;
|
||||
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn draw_img(&mut self, img_buffer: &[u8]) {
|
||||
let img = image::load_from_memory(img_buffer).unwrap();
|
||||
self.write_dynamic_image(img).await
|
||||
}
|
||||
|
||||
async fn draw_line(&mut self, color: Color, height: u32) {
|
||||
self.draw_patterned_line(color, height, LinePattern::Solid)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn draw_patterned_line(&mut self, color: Color, height: u32, pattern: LinePattern) {
|
||||
let width = self.dimensions().width;
|
||||
let mut buffer = Vec::with_capacity((height * width).try_into().unwrap());
|
||||
|
||||
for _row in 0..height {
|
||||
for col in 0..width {
|
||||
let should_draw = match pattern {
|
||||
LinePattern::Solid => true,
|
||||
LinePattern::Dashed => (col / 4) % 2 == 0, // 4 pixels on, 4 pixels off
|
||||
LinePattern::Dotted => col % 4 == 0, // 1 pixel on, 3 pixels off
|
||||
};
|
||||
|
||||
if should_draw {
|
||||
buffer.push(color.rgb());
|
||||
} else {
|
||||
buffer.push((0, 0, 0)); // Black background
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.write_buffer(buffer).await
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_ui(
|
||||
task_tracker: &TaskTracker,
|
||||
config: &config::Config,
|
||||
mut fb: impl GenericFramebuffer,
|
||||
shutdown_token: CancellationToken,
|
||||
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.");
|
||||
return;
|
||||
}
|
||||
|
||||
let colorblind_mode = config.colorblind_mode;
|
||||
let mut display_style = display_style_from_state(DisplayState::Recording, colorblind_mode);
|
||||
|
||||
task_tracker.spawn(async move {
|
||||
// this feels wrong, is there a more rusty way to do this?
|
||||
let mut img: Option<&[u8]> = None;
|
||||
if display_level == 2 {
|
||||
img = Some(
|
||||
IMAGE_DIR
|
||||
.get_file("orca.gif")
|
||||
.expect("failed to read orca.gif")
|
||||
.contents(),
|
||||
);
|
||||
} else if display_level == 3 {
|
||||
img = Some(
|
||||
IMAGE_DIR
|
||||
.get_file("eff.png")
|
||||
.expect("failed to read eff.png")
|
||||
.contents(),
|
||||
);
|
||||
}
|
||||
loop {
|
||||
if shutdown_token.is_cancelled() {
|
||||
info!("received UI shutdown");
|
||||
break;
|
||||
}
|
||||
match ui_update_rx.try_recv() {
|
||||
Ok(state) => {
|
||||
display_style = display_style_from_state(state, colorblind_mode);
|
||||
}
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
|
||||
Err(e) => error!("error receiving framebuffer update message: {e}"),
|
||||
}
|
||||
|
||||
let mut status_bar_height = 2;
|
||||
match display_level {
|
||||
2 => fb.draw_gif(img.unwrap()).await,
|
||||
3 => fb.draw_img(img.unwrap()).await,
|
||||
4 => {
|
||||
status_bar_height = fb.dimensions().height;
|
||||
}
|
||||
128 => {
|
||||
fb.draw_line(Color::Cyan, 128).await;
|
||||
fb.draw_line(Color::Pink, 102).await;
|
||||
fb.draw_line(Color::White, 76).await;
|
||||
fb.draw_line(Color::Pink, 50).await;
|
||||
fb.draw_line(Color::Cyan, 25).await;
|
||||
}
|
||||
// this branch is for ui_level 1, which is also the default if an
|
||||
// unknown value is used
|
||||
_ => {}
|
||||
};
|
||||
let (color, pattern) = display_style;
|
||||
fb.draw_patterned_line(color, status_bar_height, pattern)
|
||||
.await;
|
||||
tokio::time::sleep(Duration::from_millis(REFRESH_RATE)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
16
daemon/src/display/headless.rs
Normal file
16
daemon/src/display/headless.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use log::info;
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
use crate::config;
|
||||
use crate::display::DisplayState;
|
||||
|
||||
pub fn update_ui(
|
||||
_task_tracker: &TaskTracker,
|
||||
_config: &config::Config,
|
||||
_shutdown_token: CancellationToken,
|
||||
_ui_update_rx: Receiver<DisplayState>,
|
||||
) {
|
||||
info!("Headless mode, not spawning UI.");
|
||||
}
|
||||
28
daemon/src/display/mod.rs
Normal file
28
daemon/src/display/mod.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
use rayhunter::analysis::analyzer::EventType;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
mod generic_framebuffer;
|
||||
|
||||
pub mod headless;
|
||||
pub mod orbic;
|
||||
pub mod tmobile;
|
||||
pub mod tplink;
|
||||
pub mod tplink_framebuffer;
|
||||
pub mod tplink_onebit;
|
||||
pub mod uz801;
|
||||
pub mod wingtech;
|
||||
|
||||
/// A list of available display states
|
||||
#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
||||
pub enum DisplayState {
|
||||
/// We're recording but no warning has been found yet.
|
||||
Recording,
|
||||
/// We're not recording.
|
||||
Paused,
|
||||
/// A non-informational event has been detected.
|
||||
///
|
||||
/// Note that EventType::Informational is never sent through this. If it is, it's the same as
|
||||
/// Recording
|
||||
WarningDetected { event_type: EventType },
|
||||
}
|
||||
51
daemon/src/display/orbic.rs
Normal file
51
daemon/src/display/orbic.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use crate::config;
|
||||
use crate::display::DisplayState;
|
||||
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
|
||||
use async_trait::async_trait;
|
||||
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
const FB_PATH: &str = "/dev/fb0";
|
||||
|
||||
#[derive(Copy, Clone, Default)]
|
||||
struct Framebuffer;
|
||||
|
||||
#[async_trait]
|
||||
impl GenericFramebuffer for Framebuffer {
|
||||
fn dimensions(&self) -> Dimensions {
|
||||
// TODO actually poll for this, maybe w/ fbset?
|
||||
Dimensions {
|
||||
height: 128,
|
||||
width: 128,
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_buffer(&mut self, buffer: Vec<(u8, u8, u8)>) {
|
||||
let mut raw_buffer = Vec::with_capacity(buffer.len() * 2);
|
||||
for (r, g, b) in buffer {
|
||||
let mut rgb565: u16 = (r as u16 & 0b11111000) << 8;
|
||||
rgb565 |= (g as u16 & 0b11111100) << 3;
|
||||
rgb565 |= (b as u16) >> 3;
|
||||
raw_buffer.extend(rgb565.to_le_bytes());
|
||||
}
|
||||
|
||||
tokio::fs::write(FB_PATH, &raw_buffer).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_ui(
|
||||
task_tracker: &TaskTracker,
|
||||
config: &config::Config,
|
||||
shutdown_token: CancellationToken,
|
||||
ui_update_rx: Receiver<DisplayState>,
|
||||
) {
|
||||
generic_framebuffer::update_ui(
|
||||
task_tracker,
|
||||
config,
|
||||
Framebuffer,
|
||||
shutdown_token,
|
||||
ui_update_rx,
|
||||
)
|
||||
}
|
||||
77
daemon/src/display/tmobile.rs
Normal file
77
daemon/src/display/tmobile.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
/// 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_util::sync::CancellationToken;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config;
|
||||
use crate::display::DisplayState;
|
||||
|
||||
macro_rules! led {
|
||||
($l:expr) => {{ format!("/sys/class/leds/led:{}/blink", $l) }};
|
||||
}
|
||||
|
||||
async fn start_blinking(path: String) {
|
||||
tokio::fs::write(&path, "1").await.ok();
|
||||
}
|
||||
|
||||
async fn stop_blinking(path: String) {
|
||||
tokio::fs::write(&path, "0").await.ok();
|
||||
}
|
||||
|
||||
pub fn update_ui(
|
||||
task_tracker: &TaskTracker,
|
||||
config: &config::Config,
|
||||
shutdown_token: CancellationToken,
|
||||
mut ui_update_rx: mpsc::Receiver<DisplayState>,
|
||||
) {
|
||||
let mut invisible: bool = false;
|
||||
if config.ui_level == 0 {
|
||||
info!("Invisible mode, not spawning UI.");
|
||||
invisible = true;
|
||||
}
|
||||
task_tracker.spawn(async move {
|
||||
let mut state = DisplayState::Recording;
|
||||
let mut last_state = DisplayState::Paused;
|
||||
|
||||
loop {
|
||||
if shutdown_token.is_cancelled() {
|
||||
info!("received UI shutdown");
|
||||
break;
|
||||
}
|
||||
match ui_update_rx.try_recv() {
|
||||
Ok(new_state) => state = new_state,
|
||||
Err(mpsc::error::TryRecvError::Empty) => {}
|
||||
Err(e) => error!("error receiving ui update message: {e}"),
|
||||
};
|
||||
if invisible || state == last_state {
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
continue;
|
||||
}
|
||||
match state {
|
||||
DisplayState::Paused => {
|
||||
stop_blinking(led!("signal_blue")).await;
|
||||
stop_blinking(led!("signal_red")).await;
|
||||
start_blinking(led!("wlan_white")).await;
|
||||
}
|
||||
DisplayState::Recording => {
|
||||
stop_blinking(led!("wlan_white")).await;
|
||||
stop_blinking(led!("signal_red")).await;
|
||||
start_blinking(led!("signal_blue")).await;
|
||||
}
|
||||
DisplayState::WarningDetected { .. } => {
|
||||
stop_blinking(led!("wlan_white")).await;
|
||||
stop_blinking(led!("signal_blue")).await;
|
||||
start_blinking(led!("signal_red")).await;
|
||||
}
|
||||
}
|
||||
last_state = state;
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
31
daemon/src/display/tplink.rs
Normal file
31
daemon/src/display/tplink.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use log::info;
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
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,
|
||||
shutdown_token: CancellationToken,
|
||||
ui_update_rx: Receiver<DisplayState>,
|
||||
) {
|
||||
let display_level = config.ui_level;
|
||||
if display_level == 0 {
|
||||
info!("Invisible mode, not spawning UI.");
|
||||
}
|
||||
|
||||
// Since this is a one-time check at startup, using sync is acceptable
|
||||
// The alternative would be to make the entire initialization async
|
||||
if fs::exists(tplink_onebit::OLED_PATH).unwrap_or_default() {
|
||||
info!("detected one-bit display");
|
||||
tplink_onebit::update_ui(task_tracker, config, shutdown_token, ui_update_rx)
|
||||
} else {
|
||||
info!("fallback to framebuffer");
|
||||
tplink_framebuffer::update_ui(task_tracker, config, shutdown_token, ui_update_rx)
|
||||
}
|
||||
}
|
||||
93
daemon/src/display/tplink_framebuffer.rs
Normal file
93
daemon/src/display/tplink_framebuffer.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use async_trait::async_trait;
|
||||
use std::os::fd::AsRawFd;
|
||||
use tokio::fs::OpenOptions;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::config;
|
||||
use crate::display::DisplayState;
|
||||
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
|
||||
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
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,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GenericFramebuffer for Framebuffer {
|
||||
fn dimensions(&self) -> Dimensions {
|
||||
// TODO actually poll for this, maybe w/ fbset?
|
||||
Dimensions {
|
||||
height: 128,
|
||||
width: 128,
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_buffer(&mut self, buffer: Vec<(u8, u8, u8)>) {
|
||||
// for how to write to the buffer, consult M7350v5_en_gpl/bootable/recovery/recovery_color_oled.c
|
||||
let dimensions = self.dimensions();
|
||||
let width = dimensions.width;
|
||||
let height = buffer.len() as u32 / width;
|
||||
let mut f = OpenOptions::new().write(true).open(FB_PATH).await.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::with_capacity(buffer.len() * 2);
|
||||
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).await.unwrap();
|
||||
|
||||
// ioctl is a synchronous operation, but it's fast enough that it shouldn't block
|
||||
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,
|
||||
shutdown_token: CancellationToken,
|
||||
ui_update_rx: Receiver<DisplayState>,
|
||||
) {
|
||||
generic_framebuffer::update_ui(
|
||||
task_tracker,
|
||||
config,
|
||||
Framebuffer,
|
||||
shutdown_token,
|
||||
ui_update_rx,
|
||||
)
|
||||
}
|
||||
163
daemon/src/display/tplink_onebit.rs
Normal file
163
daemon/src/display/tplink_onebit.rs
Normal file
@@ -0,0 +1,163 @@
|
||||
/// 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_util::sync::CancellationToken;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
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,
|
||||
shutdown_token: CancellationToken,
|
||||
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(async move {
|
||||
let mut pixels = STATUS_SMILING;
|
||||
|
||||
loop {
|
||||
if shutdown_token.is_cancelled() {
|
||||
info!("received UI shutdown");
|
||||
break;
|
||||
}
|
||||
|
||||
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
|
||||
&& let Err(e) = tokio::fs::write(OLED_PATH, pixels).await
|
||||
{
|
||||
error!("failed to write to display: {e}");
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(1000)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[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
|
||||
]
|
||||
);
|
||||
}
|
||||
85
daemon/src/display/uz801.rs
Normal file
85
daemon/src/display/uz801.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
/// Display module for Uz801, light LEDs on the front of the device.
|
||||
/// DisplayState::Recording => Green LED is solid.
|
||||
/// DisplayState::Paused => Signal LED is solid blue (wifi LED).
|
||||
/// DisplayState::WarningDetected => Signal LED is solid red.
|
||||
use log::{error, info};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config;
|
||||
use crate::display::DisplayState;
|
||||
|
||||
macro_rules! led {
|
||||
($l:expr) => {{ format!("/sys/class/leds/{}/brightness", $l) }};
|
||||
}
|
||||
|
||||
async fn led_on(path: String) {
|
||||
tokio::fs::write(&path, "1").await.ok();
|
||||
}
|
||||
|
||||
async fn led_off(path: String) {
|
||||
tokio::fs::write(&path, "0").await.ok();
|
||||
}
|
||||
|
||||
pub fn update_ui(
|
||||
task_tracker: &TaskTracker,
|
||||
config: &config::Config,
|
||||
shutdown_token: CancellationToken,
|
||||
mut ui_update_rx: mpsc::Receiver<DisplayState>,
|
||||
) {
|
||||
let mut invisible: bool = false;
|
||||
if config.ui_level == 0 {
|
||||
info!("Invisible mode, not spawning UI.");
|
||||
invisible = true;
|
||||
}
|
||||
task_tracker.spawn(async move {
|
||||
let mut state = DisplayState::Recording;
|
||||
let mut last_state = DisplayState::Paused;
|
||||
let mut last_update = std::time::Instant::now();
|
||||
|
||||
loop {
|
||||
if shutdown_token.is_cancelled() {
|
||||
info!("received UI shutdown");
|
||||
break;
|
||||
}
|
||||
match ui_update_rx.try_recv() {
|
||||
Ok(new_state) => state = new_state,
|
||||
Err(mpsc::error::TryRecvError::Empty) => {}
|
||||
Err(e) => error!("error receiving ui update message: {e}"),
|
||||
};
|
||||
|
||||
// Update LEDs if state changed or if 5 seconds have passed since last update
|
||||
let now = std::time::Instant::now();
|
||||
let should_update = !invisible
|
||||
&& (state != last_state
|
||||
|| now.duration_since(last_update) >= Duration::from_secs(5));
|
||||
|
||||
if should_update {
|
||||
match state {
|
||||
DisplayState::Paused => {
|
||||
led_off(led!("red")).await;
|
||||
led_off(led!("green")).await;
|
||||
led_on(led!("wifi")).await;
|
||||
}
|
||||
DisplayState::Recording => {
|
||||
led_off(led!("red")).await;
|
||||
led_off(led!("wifi")).await;
|
||||
led_on(led!("green")).await;
|
||||
}
|
||||
DisplayState::WarningDetected { .. } => {
|
||||
led_off(led!("green")).await;
|
||||
led_off(led!("wifi")).await;
|
||||
led_on(led!("red")).await;
|
||||
}
|
||||
}
|
||||
last_state = state;
|
||||
last_update = now;
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
56
daemon/src/display/wingtech.rs
Normal file
56
daemon/src/display/wingtech.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use crate::config;
|
||||
use crate::display::DisplayState;
|
||||
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
|
||||
/// Display support for the Wingtech CT2MHS01 hotspot.
|
||||
///
|
||||
/// Tested on (from `/etc/wt_version`):
|
||||
/// WT_INNER_VERSION=SW_Q89323AA1_V057_M10_CRICKET_USR_MP
|
||||
/// WT_PRODUCTION_VERSION=CT2MHS01_0.04.55
|
||||
/// WT_HARDWARE_VERSION=89323_1_20
|
||||
use async_trait::async_trait;
|
||||
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
const FB_PATH: &str = "/dev/fb0";
|
||||
|
||||
#[derive(Copy, Clone, Default)]
|
||||
struct Framebuffer;
|
||||
|
||||
#[async_trait]
|
||||
impl GenericFramebuffer for Framebuffer {
|
||||
fn dimensions(&self) -> Dimensions {
|
||||
Dimensions {
|
||||
height: 128,
|
||||
width: 160,
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_buffer(&mut self, buffer: Vec<(u8, u8, u8)>) {
|
||||
let mut raw_buffer = Vec::with_capacity(buffer.len() * 2);
|
||||
for (r, g, b) in buffer {
|
||||
let mut rgb565: u16 = (r as u16 & 0b11111000) << 8;
|
||||
rgb565 |= (g as u16 & 0b11111100) << 3;
|
||||
rgb565 |= (b as u16) >> 3;
|
||||
raw_buffer.extend(rgb565.to_le_bytes());
|
||||
}
|
||||
|
||||
tokio::fs::write(FB_PATH, &raw_buffer).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_ui(
|
||||
task_tracker: &TaskTracker,
|
||||
config: &config::Config,
|
||||
shutdown_token: CancellationToken,
|
||||
ui_update_rx: Receiver<DisplayState>,
|
||||
) {
|
||||
generic_framebuffer::update_ui(
|
||||
task_tracker,
|
||||
config,
|
||||
Framebuffer,
|
||||
shutdown_token,
|
||||
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}")]
|
||||
@@ -15,4 +15,10 @@ pub enum RayhunterError{
|
||||
QmdlStoreError(#[from] RecordingStoreError),
|
||||
#[error("No QMDL store found at path {0}, but can't create a new one due to debug mode")]
|
||||
NoStoreDebugMode(String),
|
||||
#[error("Error parsing file to determine battery level")]
|
||||
BatteryLevelParseError,
|
||||
#[error("Error parsing file to determine whether device is plugged in")]
|
||||
BatteryPluggedInStatusParseError,
|
||||
#[error("The requested functionality is not supported for this device")]
|
||||
FunctionNotSupportedForDeviceError,
|
||||
}
|
||||
132
daemon/src/key_input.rs
Normal file
132
daemon/src/key_input.rs
Normal file
@@ -0,0 +1,132 @@
|
||||
use log::{error, info};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::fs::File;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::sync::mpsc::Sender;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
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>,
|
||||
cancellation_token: CancellationToken,
|
||||
) {
|
||||
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! {
|
||||
_ = cancellation_token.cancelled() => {
|
||||
info!("received key input shutdown");
|
||||
return;
|
||||
}
|
||||
result = file.read_exact(&mut buffer) => {
|
||||
if let Err(e) = result {
|
||||
error!("failed to read key input: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let event = parse_event(buffer);
|
||||
|
||||
let now = Instant::now();
|
||||
|
||||
// On orbic it was observed that pressing the power button can trigger many successive
|
||||
// events. Drop events that are too close together.
|
||||
if let Some(last_time) = last_event_time
|
||||
&& now.duration_since(last_time) < Duration::from_millis(50)
|
||||
{
|
||||
last_event_time = Some(now);
|
||||
continue;
|
||||
}
|
||||
last_event_time = Some(now);
|
||||
|
||||
match event {
|
||||
Event::KeyUp => {
|
||||
if let Some(last_keyup_instant) = last_keyup {
|
||||
let elapsed = now.duration_since(last_keyup_instant);
|
||||
|
||||
if elapsed >= Duration::from_millis(100)
|
||||
&& elapsed <= Duration::from_millis(800)
|
||||
{
|
||||
if let Err(e) = diag_tx.send(DiagDeviceCtrlMessage::StopRecording).await
|
||||
{
|
||||
error!("Failed to send StopRecording: {e}");
|
||||
}
|
||||
if let Err(e) = diag_tx
|
||||
.send(DiagDeviceCtrlMessage::StartRecording { response_tx: None })
|
||||
.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));
|
||||
}
|
||||
}
|
||||
71
daemon/src/lib.rs
Normal file
71
daemon/src/lib.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
pub mod analysis;
|
||||
pub mod battery;
|
||||
pub mod config;
|
||||
pub mod diag;
|
||||
pub mod display;
|
||||
pub mod error;
|
||||
pub mod key_input;
|
||||
pub mod notifications;
|
||||
pub mod pcap;
|
||||
pub mod qmdl_store;
|
||||
pub mod server;
|
||||
pub mod stats;
|
||||
|
||||
#[cfg(feature = "apidocs")]
|
||||
use utoipa::OpenApi;
|
||||
|
||||
// Add anotated paths to api docs
|
||||
#[cfg(feature = "apidocs")]
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
info(
|
||||
description = "OpenAPI documentation for Rayhunter daemon\n\n**Note:** API endpoints are subject to change as needs arise, though we will try to keep them as stable as possible and notify about breaking changes in the changelogs for new versions.\n\nNo endpoints require any authentication. To use the in-browser execution on this page, you may need to disable CORS temporarily for your browser.",
|
||||
license(
|
||||
name = "GNU General Public License v3.0",
|
||||
url = "https://github.com/EFForg/rayhunter/blob/main/LICENSE"
|
||||
)
|
||||
),
|
||||
paths(
|
||||
pcap::get_pcap,
|
||||
server::get_qmdl,
|
||||
server::get_zip,
|
||||
stats::get_system_stats,
|
||||
stats::get_qmdl_manifest,
|
||||
stats::get_log,
|
||||
diag::start_recording,
|
||||
diag::stop_recording,
|
||||
diag::delete_recording,
|
||||
diag::delete_all_recordings,
|
||||
diag::get_analysis_report,
|
||||
analysis::get_analysis_status,
|
||||
analysis::start_analysis,
|
||||
server::get_config,
|
||||
server::set_config,
|
||||
server::test_notification,
|
||||
server::get_time,
|
||||
server::set_time_offset,
|
||||
server::debug_set_display_state
|
||||
),
|
||||
servers(
|
||||
(
|
||||
url = "http://localhost:8080",
|
||||
description = "ADB port bridge"
|
||||
),
|
||||
(
|
||||
url = "http://192.168.1.1:8080",
|
||||
description = "Orbic WiFi GUI"
|
||||
),
|
||||
(
|
||||
url = "http://192.168.0.1:8080",
|
||||
description = "TPLink WiFi GUI"
|
||||
),
|
||||
)
|
||||
)]
|
||||
pub struct ApiDocs;
|
||||
|
||||
#[cfg(feature = "apidocs")]
|
||||
impl ApiDocs {
|
||||
pub fn generate() -> String {
|
||||
ApiDocs::openapi().to_pretty_json().unwrap()
|
||||
}
|
||||
}
|
||||
319
daemon/src/main.rs
Normal file
319
daemon/src/main.rs
Normal file
@@ -0,0 +1,319 @@
|
||||
mod analysis;
|
||||
mod battery;
|
||||
mod config;
|
||||
mod diag;
|
||||
mod display;
|
||||
mod error;
|
||||
mod key_input;
|
||||
mod notifications;
|
||||
mod pcap;
|
||||
mod qmdl_store;
|
||||
mod server;
|
||||
mod stats;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::battery::run_battery_notification_worker;
|
||||
use crate::config::{parse_args, parse_config};
|
||||
use crate::diag::run_diag_read_thread;
|
||||
use crate::error::RayhunterError;
|
||||
use crate::notifications::{NotificationService, run_notification_worker};
|
||||
use crate::pcap::get_pcap;
|
||||
use crate::qmdl_store::RecordingStore;
|
||||
use crate::server::{
|
||||
ServerState, debug_set_display_state, get_config, get_qmdl, get_time, get_zip, serve_static,
|
||||
set_config, set_time_offset, test_notification,
|
||||
};
|
||||
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 stats::get_log;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::select;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::sync::mpsc::{self, Sender};
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
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/log", get(get_log))
|
||||
.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("/api/test-notification", post(test_notification))
|
||||
.route("/api/time", get(get_time))
|
||||
.route("/api/time-offset", post(set_time_offset))
|
||||
.route("/api/debug/display-state", post(debug_set_display_state))
|
||||
.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>,
|
||||
shutdown_token: CancellationToken,
|
||||
) -> 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(shutdown_token.cancelled_owned())
|
||||
.await
|
||||
.unwrap();
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
fn run_shutdown_thread(
|
||||
task_tracker: &TaskTracker,
|
||||
diag_device_sender: Sender<DiagDeviceCtrlMessage>,
|
||||
shutdown_token: CancellationToken,
|
||||
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}");
|
||||
}
|
||||
}
|
||||
_ = shutdown_token.cancelled() => {}
|
||||
}
|
||||
|
||||
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!");
|
||||
}
|
||||
|
||||
shutdown_token.cancel();
|
||||
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(flavor = "current_thread")]
|
||||
async fn main() -> Result<(), RayhunterError> {
|
||||
rayhunter::init_logging(log::LevelFilter::Info);
|
||||
|
||||
#[cfg(feature = "rustcrypto-tls")]
|
||||
{
|
||||
rustls_rustcrypto::provider()
|
||||
.install_default()
|
||||
.expect("Couldn't install rustcrypto provider");
|
||||
}
|
||||
|
||||
let args = parse_args();
|
||||
|
||||
loop {
|
||||
let config = parse_config(&args.config_path).await?;
|
||||
if !run_with_config(&args, config).await? {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_with_config(
|
||||
args: &config::Args,
|
||||
config: config::Config,
|
||||
) -> Result<bool, RayhunterError> {
|
||||
// TaskTrackers give us an interface to spawn tokio threads, and then
|
||||
// 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 restart_token = CancellationToken::new();
|
||||
let shutdown_token = restart_token.child_token();
|
||||
// Ensure shutdown_token is cancelled when this function exits for any
|
||||
// reason (e.g. diag device init failure), so all spawned tasks get
|
||||
// signaled to stop.
|
||||
let _shutdown_guard = shutdown_token.clone().drop_guard();
|
||||
|
||||
let notification_service = NotificationService::new(config.ntfy_url.clone());
|
||||
|
||||
if !config.debug_mode {
|
||||
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,
|
||||
diag_tx.clone(),
|
||||
ui_update_tx.clone(),
|
||||
qmdl_store_lock.clone(),
|
||||
analysis_tx.clone(),
|
||||
config.analyzers.clone(),
|
||||
notification_service.new_handler(),
|
||||
config.min_space_to_start_recording_mb,
|
||||
config.min_space_to_continue_recording_mb,
|
||||
);
|
||||
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,
|
||||
Device::Uz801 => display::uz801::update_ui,
|
||||
};
|
||||
update_ui(&task_tracker, &config, shutdown_token.clone(), ui_update_rx);
|
||||
|
||||
info!("Starting Key Input service");
|
||||
key_input::run_key_input_thread(
|
||||
&task_tracker,
|
||||
&config,
|
||||
diag_tx.clone(),
|
||||
shutdown_token.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
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(),
|
||||
);
|
||||
|
||||
run_shutdown_thread(
|
||||
&task_tracker,
|
||||
diag_tx.clone(),
|
||||
shutdown_token.clone(),
|
||||
qmdl_store_lock.clone(),
|
||||
analysis_tx.clone(),
|
||||
);
|
||||
|
||||
run_battery_notification_worker(
|
||||
&task_tracker,
|
||||
config.device.clone(),
|
||||
notification_service.new_handler(),
|
||||
shutdown_token.clone(),
|
||||
);
|
||||
|
||||
run_notification_worker(
|
||||
&task_tracker,
|
||||
notification_service,
|
||||
config.enabled_notifications.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,
|
||||
analysis_status_lock,
|
||||
analysis_sender: analysis_tx,
|
||||
daemon_restart_token: restart_token.clone(),
|
||||
ui_update_sender: Some(ui_update_tx),
|
||||
});
|
||||
run_server(&task_tracker, state, shutdown_token.clone()).await;
|
||||
|
||||
task_tracker.close();
|
||||
task_tracker.wait().await;
|
||||
|
||||
info!("see you space cowboy...");
|
||||
Ok(restart_token.is_cancelled())
|
||||
}
|
||||
|
||||
#[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();
|
||||
}
|
||||
}
|
||||
383
daemon/src/notifications.rs
Normal file
383
daemon/src/notifications.rs
Normal file
@@ -0,0 +1,383 @@
|
||||
use std::{
|
||||
cmp::min,
|
||||
collections::HashMap,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use log::error;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use tokio::sync::mpsc::{self, error::TryRecvError};
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum NotificationError {
|
||||
#[error("HTTP request failed: {0}")]
|
||||
RequestFailed(#[from] reqwest::Error),
|
||||
#[error("Server returned error status: {0}")]
|
||||
HttpError(reqwest::StatusCode),
|
||||
}
|
||||
|
||||
/// Enum of valid notification types
|
||||
#[derive(Hash, Eq, PartialEq, Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
||||
pub enum NotificationType {
|
||||
Warning,
|
||||
LowBattery,
|
||||
}
|
||||
|
||||
pub struct Notification {
|
||||
notification_type: NotificationType,
|
||||
message: String,
|
||||
debounce: Option<Duration>,
|
||||
}
|
||||
|
||||
impl Notification {
|
||||
pub fn new(
|
||||
notification_type: NotificationType,
|
||||
message: String,
|
||||
debounce: Option<Duration>,
|
||||
) -> Self {
|
||||
Notification {
|
||||
notification_type,
|
||||
message,
|
||||
debounce,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NotificationStatus {
|
||||
message: String,
|
||||
needs_sending: bool,
|
||||
last_sent: Option<Instant>,
|
||||
last_attempt: Option<Instant>,
|
||||
failed_since_last_success: u32,
|
||||
}
|
||||
|
||||
pub struct NotificationService {
|
||||
url: Option<String>,
|
||||
tx: mpsc::Sender<Notification>,
|
||||
rx: mpsc::Receiver<Notification>,
|
||||
}
|
||||
|
||||
impl NotificationService {
|
||||
pub fn new(url: Option<String>) -> Self {
|
||||
let (tx, rx) = mpsc::channel(10);
|
||||
Self { url, tx, rx }
|
||||
}
|
||||
|
||||
pub fn new_handler(&self) -> mpsc::Sender<Notification> {
|
||||
self.tx.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends a notification message to the specified URL.
|
||||
pub async fn send_notification(
|
||||
http_client: &reqwest::Client,
|
||||
url: &str,
|
||||
message: String,
|
||||
) -> Result<(), NotificationError> {
|
||||
let response = http_client.post(url).body(message).send().await?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(NotificationError::HttpError(response.status()))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_notification_worker(
|
||||
task_tracker: &TaskTracker,
|
||||
mut notification_service: NotificationService,
|
||||
enabled_notifications: Vec<NotificationType>,
|
||||
) {
|
||||
task_tracker.spawn(async move {
|
||||
if let Some(url) = notification_service.url
|
||||
&& !url.is_empty()
|
||||
{
|
||||
let mut notification_statuses = HashMap::new();
|
||||
let http_client = reqwest::Client::new();
|
||||
|
||||
loop {
|
||||
// Get any notifications since the last time we checked
|
||||
loop {
|
||||
match notification_service.rx.try_recv() {
|
||||
Ok(notification) => {
|
||||
if !enabled_notifications.contains(¬ification.notification_type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let status = notification_statuses
|
||||
.entry(notification.notification_type)
|
||||
.or_insert_with(|| NotificationStatus {
|
||||
message: "".to_string(),
|
||||
needs_sending: true,
|
||||
last_sent: None,
|
||||
last_attempt: None,
|
||||
failed_since_last_success: 0,
|
||||
});
|
||||
// Ignore if we're in the debounce period
|
||||
if let Some(debounce) = notification.debounce
|
||||
&& let Some(last_sent) = status.last_sent
|
||||
&& last_sent.elapsed() < debounce
|
||||
{
|
||||
continue;
|
||||
}
|
||||
status.message = notification.message;
|
||||
status.needs_sending = true;
|
||||
}
|
||||
Err(TryRecvError::Empty) => {
|
||||
break;
|
||||
}
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt to send pending notifications
|
||||
for notification in notification_statuses.values_mut() {
|
||||
if !notification.needs_sending {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Backoff retries, up to a maximum of 256 seconds.
|
||||
if let Some(last_attempt) = notification.last_attempt {
|
||||
let min_wait_time = Duration::from_secs(
|
||||
2u64.pow(min(notification.failed_since_last_success, 8)),
|
||||
);
|
||||
if last_attempt.elapsed() < min_wait_time {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
match send_notification(&http_client, &url, notification.message.clone()).await
|
||||
{
|
||||
Ok(()) => {
|
||||
notification.last_sent = Some(Instant::now());
|
||||
notification.failed_since_last_success = 0;
|
||||
notification.needs_sending = false;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to send notification: {e}");
|
||||
notification.failed_since_last_success += 1;
|
||||
notification.last_attempt = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
}
|
||||
// If there's no url to send to we'll just discard the notifications
|
||||
else {
|
||||
loop {
|
||||
if notification_service.rx.recv().await.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::{Router, body::Bytes, extract::State, routing::post};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TestServerState {
|
||||
received_messages: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
async fn capture_notification(
|
||||
State(state): State<TestServerState>,
|
||||
body: Bytes,
|
||||
) -> &'static str {
|
||||
let message = String::from_utf8_lossy(&body).to_string();
|
||||
state.received_messages.lock().await.push(message);
|
||||
"OK"
|
||||
}
|
||||
|
||||
async fn setup_test_server() -> (Arc<Mutex<Vec<String>>>, String) {
|
||||
#[cfg(feature = "rustcrypto-tls")]
|
||||
{
|
||||
let _ = rustls_rustcrypto::provider().install_default();
|
||||
}
|
||||
|
||||
let received_messages = Arc::new(Mutex::new(Vec::new()));
|
||||
let test_state = TestServerState {
|
||||
received_messages: received_messages.clone(),
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", post(capture_notification))
|
||||
.with_state(test_state);
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let url = format!("http://{}", addr);
|
||||
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
(received_messages, url)
|
||||
}
|
||||
|
||||
async fn cleanup_worker(sender: mpsc::Sender<Notification>, tracker: TaskTracker) {
|
||||
drop(sender);
|
||||
tracker.close();
|
||||
tracker.wait().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_notification_worker_sends_message() {
|
||||
let (received_messages, url) = setup_test_server().await;
|
||||
|
||||
let task_tracker = TaskTracker::new();
|
||||
let notification_service = NotificationService::new(Some(url));
|
||||
let notification_sender = notification_service.new_handler();
|
||||
|
||||
run_notification_worker(
|
||||
&task_tracker,
|
||||
notification_service,
|
||||
vec![NotificationType::Warning],
|
||||
);
|
||||
|
||||
notification_sender
|
||||
.send(Notification::new(
|
||||
NotificationType::Warning,
|
||||
"test warning message".to_string(),
|
||||
None,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
|
||||
let messages = received_messages.lock().await;
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert_eq!(messages[0], "test warning message");
|
||||
drop(messages);
|
||||
|
||||
cleanup_worker(notification_sender, task_tracker).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_notification_worker_filters_disabled_types() {
|
||||
let (received_messages, url) = setup_test_server().await;
|
||||
|
||||
let task_tracker = TaskTracker::new();
|
||||
let notification_service = NotificationService::new(Some(url));
|
||||
let notification_sender = notification_service.new_handler();
|
||||
|
||||
run_notification_worker(
|
||||
&task_tracker,
|
||||
notification_service,
|
||||
vec![NotificationType::Warning],
|
||||
);
|
||||
|
||||
notification_sender
|
||||
.send(Notification::new(
|
||||
NotificationType::Warning,
|
||||
"test warning".to_string(),
|
||||
None,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
notification_sender
|
||||
.send(Notification::new(
|
||||
NotificationType::LowBattery,
|
||||
"test low battery".to_string(),
|
||||
None,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
|
||||
let messages = received_messages.lock().await;
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert_eq!(messages[0], "test warning");
|
||||
drop(messages);
|
||||
|
||||
cleanup_worker(notification_sender, task_tracker).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_notification_worker_sends_enabled_types() {
|
||||
let (received_messages, url) = setup_test_server().await;
|
||||
|
||||
let task_tracker = TaskTracker::new();
|
||||
let notification_service = NotificationService::new(Some(url));
|
||||
let notification_sender = notification_service.new_handler();
|
||||
|
||||
run_notification_worker(
|
||||
&task_tracker,
|
||||
notification_service,
|
||||
vec![NotificationType::Warning, NotificationType::LowBattery],
|
||||
);
|
||||
|
||||
notification_sender
|
||||
.send(Notification::new(
|
||||
NotificationType::Warning,
|
||||
"test warning".to_string(),
|
||||
None,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
notification_sender
|
||||
.send(Notification::new(
|
||||
NotificationType::LowBattery,
|
||||
"test low battery".to_string(),
|
||||
None,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
|
||||
let messages = received_messages.lock().await;
|
||||
assert_eq!(messages.len(), 2);
|
||||
// these are interchangeable, ordering not guaranteed
|
||||
assert!(messages.contains(&"test warning".to_string()));
|
||||
assert!(messages.contains(&"test low battery".to_string()));
|
||||
drop(messages);
|
||||
|
||||
cleanup_worker(notification_sender, task_tracker).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_notification_worker_with_no_url() {
|
||||
let task_tracker = TaskTracker::new();
|
||||
let notification_service = NotificationService::new(None);
|
||||
let notification_sender = notification_service.new_handler();
|
||||
|
||||
run_notification_worker(
|
||||
&task_tracker,
|
||||
notification_service,
|
||||
vec![NotificationType::Warning],
|
||||
);
|
||||
|
||||
notification_sender
|
||||
.send(Notification::new(
|
||||
NotificationType::Warning,
|
||||
"test warning".to_string(),
|
||||
None,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
cleanup_worker(notification_sender, task_tracker).await;
|
||||
}
|
||||
}
|
||||
100
daemon/src/pcap.rs
Normal file
100
daemon/src/pcap.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
use crate::server::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.
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
get,
|
||||
path = "/api/pcap/{name}",
|
||||
tag = "Recordings",
|
||||
responses(
|
||||
(status = StatusCode::OK, description = "PCAP conversion successful", content_type = "application/vnd.tcpdump.pcap"),
|
||||
(status = StatusCode::NOT_FOUND, description = "Could not find file {name}"),
|
||||
(status = StatusCode::SERVICE_UNAVAILABLE, description = "QMDL file is empty")
|
||||
),
|
||||
params(
|
||||
("name" = String, Path, description = "QMDL filename to convert and download")
|
||||
),
|
||||
summary = "Download a PCAP file",
|
||||
description = "Stream a PCAP file to a client in chunks by converting the QMDL data for file {name} written so far."
|
||||
))]
|
||||
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.uncompressed_qmdl_size_bytes == 0 {
|
||||
return Err((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"QMDL file is empty, try again in a bit!".to_string(),
|
||||
));
|
||||
}
|
||||
let qmdl_reader = qmdl_store
|
||||
.open_entry_qmdl(entry_index)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?;
|
||||
let (reader, writer) = duplex(1024);
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = generate_pcap_data(writer, qmdl_reader).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, mut reader: QmdlReader<R>) -> Result<(), Error>
|
||||
where
|
||||
W: AsyncWrite + Unpin + Send,
|
||||
R: AsyncRead + Unpin,
|
||||
{
|
||||
let mut pcap_writer = GsmtapPcapWriter::new(writer).await?;
|
||||
pcap_writer.write_iface_header().await?;
|
||||
|
||||
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(())
|
||||
}
|
||||
573
daemon/src/qmdl_store.rs
Normal file
573
daemon/src/qmdl_store.rs
Normal file
@@ -0,0 +1,573 @@
|
||||
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::qmdl::QmdlReader;
|
||||
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>,
|
||||
}
|
||||
|
||||
/// The structure of an entry in the QMDL manifest table
|
||||
#[derive(Deserialize, Serialize, Clone, PartialEq, Debug)]
|
||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
||||
pub struct ManifestEntry {
|
||||
/// The name of the entry
|
||||
pub name: String,
|
||||
/// The system time when recording began
|
||||
#[cfg_attr(feature = "apidocs", schema(value_type = String))]
|
||||
pub start_time: DateTime<Local>,
|
||||
/// The system time when the last message was recorded to the file
|
||||
#[cfg_attr(feature = "apidocs", schema(value_type = String))]
|
||||
pub last_message_time: Option<DateTime<Local>>,
|
||||
/// The size of the uncompressed QMDL data in bytes. Previously this was
|
||||
/// called `qmdl_size_bytes`, so alias it for backwards compatibility.
|
||||
#[serde(alias = "qmdl_size_bytes")]
|
||||
pub uncompressed_qmdl_size_bytes: usize,
|
||||
/// The rayhunter daemon version which generated the file
|
||||
pub rayhunter_version: Option<String>,
|
||||
/// The OS which created the file
|
||||
pub system_os: Option<String>,
|
||||
/// The architecture on which the OS was running
|
||||
pub arch: Option<String>,
|
||||
#[serde(default)]
|
||||
pub stop_reason: Option<String>,
|
||||
#[serde(default)]
|
||||
pub compressed: bool,
|
||||
}
|
||||
|
||||
impl ManifestEntry {
|
||||
fn new() -> Self {
|
||||
let now = rayhunter::clock::get_adjusted_now();
|
||||
let metadata = RuntimeMetadata::new();
|
||||
ManifestEntry {
|
||||
name: format!("{}", now.timestamp()),
|
||||
start_time: now,
|
||||
last_message_time: None,
|
||||
uncompressed_qmdl_size_bytes: 0,
|
||||
rayhunter_version: Some(metadata.rayhunter_version),
|
||||
system_os: Some(metadata.system_os),
|
||||
arch: Some(metadata.arch),
|
||||
stop_reason: None,
|
||||
compressed: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_qmdl_filepath<P: AsRef<Path>>(&self, path: P) -> PathBuf {
|
||||
let mut filepath = path.as_ref().join(&self.name);
|
||||
if self.compressed {
|
||||
filepath.set_extension("qmdl.gz");
|
||||
} else {
|
||||
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"
|
||||
// or "<timestamp>.qmdl.gz", 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;
|
||||
};
|
||||
|
||||
let (stem, compressed) = if filename.ends_with(".qmdl") {
|
||||
(filename.trim_end_matches(".qmdl"), false)
|
||||
} else if filename.ends_with(".qmdl.gz") {
|
||||
(filename.trim_end_matches(".qmdl.gz"), true)
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
|
||||
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(),
|
||||
compressed,
|
||||
start_time: start_time.into(),
|
||||
last_message_time: Some(last_message_time.into()),
|
||||
uncompressed_qmdl_size_bytes: metadata.size() as usize,
|
||||
rayhunter_version: None,
|
||||
system_os: None,
|
||||
arch: None,
|
||||
stop_reason: 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<QmdlReader<File>, RecordingStoreError> {
|
||||
let entry = &self.manifest.entries[entry_index];
|
||||
let file = File::open(entry.get_qmdl_filepath(&self.path))
|
||||
.await
|
||||
.map_err(RecordingStoreError::ReadFileError)?;
|
||||
Ok(QmdlReader::new(
|
||||
file,
|
||||
entry.compressed,
|
||||
Some(entry.uncompressed_qmdl_size_bytes),
|
||||
))
|
||||
}
|
||||
|
||||
// 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].uncompressed_qmdl_size_bytes = size_bytes;
|
||||
self.manifest.entries[entry_index].last_message_time =
|
||||
Some(rayhunter::clock::get_adjusted_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 set_current_stop_reason(
|
||||
&mut self,
|
||||
reason: String,
|
||||
) -> Result<(), RecordingStoreError> {
|
||||
if let Some(idx) = self.current_entry {
|
||||
self.manifest.entries[idx].stop_reason = Some(reason);
|
||||
self.write_manifest().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_current_entry(&self, name: &str) -> bool {
|
||||
match self.current_entry {
|
||||
Some(idx) => match self.manifest.entries.get(idx) {
|
||||
Some(entry) => entry.name == name,
|
||||
None => false,
|
||||
},
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_entry(&mut self, name: &str) -> Result<(), RecordingStoreError> {
|
||||
let entry_to_delete_idx = self
|
||||
.manifest
|
||||
.entries
|
||||
.iter()
|
||||
.position(|entry| entry.name == name)
|
||||
.ok_or(RecordingStoreError::NoSuchEntryError)?;
|
||||
match self.current_entry {
|
||||
Some(current_entry) if current_entry == entry_to_delete_idx => {
|
||||
self.close_current_entry().await?;
|
||||
}
|
||||
Some(current_entry) => {
|
||||
self.current_entry = Some(current_entry - 1);
|
||||
}
|
||||
None => {}
|
||||
};
|
||||
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(())
|
||||
}
|
||||
|
||||
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].uncompressed_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());
|
||||
}
|
||||
}
|
||||
530
daemon/src/server.rs
Normal file
530
daemon/src/server.rs
Normal file
@@ -0,0 +1,530 @@
|
||||
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 chrono::{DateTime, Local};
|
||||
use log::{error, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::fs::write;
|
||||
use tokio::io::copy;
|
||||
use tokio::io::duplex;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::sync::mpsc::Sender;
|
||||
use tokio_util::compat::FuturesAsyncWriteCompatExt;
|
||||
use tokio_util::io::ReaderStream;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::analysis::{AnalysisCtrlMessage, AnalysisStatus};
|
||||
use crate::config::Config;
|
||||
use crate::diag::DiagDeviceCtrlMessage;
|
||||
use crate::display::DisplayState;
|
||||
use crate::pcap::generate_pcap_data;
|
||||
use crate::qmdl_store::RecordingStore;
|
||||
|
||||
pub struct ServerState {
|
||||
pub config_path: String,
|
||||
pub config: Config,
|
||||
pub qmdl_store_lock: Arc<RwLock<RecordingStore>>,
|
||||
pub diag_device_ctrl_sender: Sender<DiagDeviceCtrlMessage>,
|
||||
pub analysis_status_lock: Arc<RwLock<AnalysisStatus>>,
|
||||
pub analysis_sender: Sender<AnalysisCtrlMessage>,
|
||||
pub daemon_restart_token: CancellationToken,
|
||||
pub ui_update_sender: Option<Sender<DisplayState>>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
get,
|
||||
path = "/api/qmdl/{name}",
|
||||
tag = "Recordings",
|
||||
responses(
|
||||
(status = StatusCode::OK, description = "QMDL download successful", content_type = "application/octet-stream"),
|
||||
(status = StatusCode::NOT_FOUND, description = "Could not find file {name}"),
|
||||
(status = StatusCode::SERVICE_UNAVAILABLE, description = "QMDL file is empty, or error opening file")
|
||||
),
|
||||
params(
|
||||
("name" = String, Path, description = "QMDL filename to convert and download")
|
||||
),
|
||||
summary = "Download a QMDL file",
|
||||
description = "Stream the QMDL file {name} to the client."
|
||||
))]
|
||||
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_reader = qmdl_store
|
||||
.open_entry_qmdl(entry_index)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("error opening QMDL file: {err}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let headers = [
|
||||
(CONTENT_TYPE, "application/octet-stream"),
|
||||
(
|
||||
CONTENT_LENGTH,
|
||||
&entry.uncompressed_qmdl_size_bytes.to_string(),
|
||||
),
|
||||
];
|
||||
let body = Body::from_stream(qmdl_reader.as_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_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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
get,
|
||||
path = "/api/config",
|
||||
tag = "Configuration",
|
||||
responses(
|
||||
(status = StatusCode::OK, description = "Success", body = Config)
|
||||
),
|
||||
summary = "Get config",
|
||||
description = "Show the running configuration for Rayhunter."
|
||||
))]
|
||||
pub async fn get_config(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
) -> Result<Json<Config>, (StatusCode, String)> {
|
||||
Ok(Json(state.config.clone()))
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
post,
|
||||
path = "/api/config",
|
||||
tag = "Configuration",
|
||||
request_body(
|
||||
content = Option<[Config]>,
|
||||
description = "Any or all configuration elements from the valid config schema to be altered may be passed. Invalid keys will be discarded. Invalid values or value types will return an error."
|
||||
),
|
||||
responses(
|
||||
(status = StatusCode::ACCEPTED, description = "Success"),
|
||||
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Failed to parse or write config file"),
|
||||
(status = 422, description = "Failed to deserialize JSON body")
|
||||
),
|
||||
summary = "Set config",
|
||||
description = "Write a new configuration for Rayhunter and trigger a restart."
|
||||
))]
|
||||
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
|
||||
state.daemon_restart_token.cancel();
|
||||
Ok((
|
||||
StatusCode::ACCEPTED,
|
||||
"wrote config and triggered restart".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
post,
|
||||
path = "/api/test-notification",
|
||||
tag = "Configuration",
|
||||
responses(
|
||||
(status = StatusCode::OK, description = "Success"),
|
||||
(status = StatusCode::BAD_REQUEST, description = "No notification URL set"),
|
||||
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Failed to send HTTP request. Ensure your device can reach the internet.")
|
||||
),
|
||||
summary = "Test ntfy notification",
|
||||
description = "Send a test notification to the ntfy_url in the running configuration for Rayhunter."
|
||||
))]
|
||||
pub async fn test_notification(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||
let url = state.config.ntfy_url.as_ref().ok_or((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"No notification URL configured".to_string(),
|
||||
))?;
|
||||
|
||||
if url.is_empty() {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Notification URL is empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let http_client = reqwest::Client::new();
|
||||
let message = "Test notification from Rayhunter".to_string();
|
||||
|
||||
crate::notifications::send_notification(&http_client, url, message)
|
||||
.await
|
||||
.map(|()| {
|
||||
(
|
||||
StatusCode::OK,
|
||||
"Test notification sent successfully".to_string(),
|
||||
)
|
||||
})
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to send test notification: {e}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Response for GET /api/time
|
||||
#[derive(Serialize)]
|
||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
||||
pub struct TimeResponse {
|
||||
/// The raw system time (without clock offset)
|
||||
#[cfg_attr(feature = "apidocs", schema(value_type = String))]
|
||||
pub system_time: DateTime<Local>,
|
||||
/// The adjusted time (system time + offset)
|
||||
#[cfg_attr(feature = "apidocs", schema(value_type = String))]
|
||||
pub adjusted_time: DateTime<Local>,
|
||||
/// The current offset in seconds
|
||||
pub offset_seconds: i64,
|
||||
}
|
||||
|
||||
/// Request for POST /api/time-offset
|
||||
#[derive(Deserialize)]
|
||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
||||
pub struct SetTimeOffsetRequest {
|
||||
/// The offset to set, in seconds
|
||||
pub offset_seconds: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
get,
|
||||
path = "/api/time",
|
||||
tag = "Configuration",
|
||||
responses(
|
||||
(status = StatusCode::OK, description = "Success", body = TimeResponse)
|
||||
),
|
||||
summary = "Get time",
|
||||
description = "Get the current time and offset (in seconds) of the device."
|
||||
))]
|
||||
pub async fn get_time() -> Json<TimeResponse> {
|
||||
let system_time = Local::now();
|
||||
let adjusted_time = rayhunter::clock::get_adjusted_now();
|
||||
let offset_seconds = adjusted_time
|
||||
.signed_duration_since(system_time)
|
||||
.num_seconds();
|
||||
Json(TimeResponse {
|
||||
system_time,
|
||||
adjusted_time,
|
||||
offset_seconds,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
get,
|
||||
path = "/api/time-offset",
|
||||
tag = "Configuration",
|
||||
request_body(
|
||||
content = SetTimeOffsetRequest
|
||||
),
|
||||
responses(
|
||||
(status = StatusCode::OK, description = "Success", body = TimeResponse)
|
||||
),
|
||||
summary = "Set time offset",
|
||||
description = "Set the difference (in seconds) between the system time and the adjusted time for Rayhunter."
|
||||
))]
|
||||
pub async fn set_time_offset(Json(req): Json<SetTimeOffsetRequest>) -> StatusCode {
|
||||
rayhunter::clock::set_offset(chrono::TimeDelta::seconds(req.offset_seconds));
|
||||
StatusCode::OK
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
get,
|
||||
path = "/api/zip/{name}",
|
||||
tag = "Recordings",
|
||||
responses(
|
||||
(status = StatusCode::OK, description = "ZIP download successful. It is possible that if the PCAP fails to convert, the same status will be returned, but the file will contain only the QMDL file.", content_type = "application/zip"),
|
||||
(status = StatusCode::NOT_FOUND, description = "Could not find file {name}"),
|
||||
(status = StatusCode::SERVICE_UNAVAILABLE, description = "QMDL file is empty, or error opening file")
|
||||
),
|
||||
params(
|
||||
("name" = String, Path, description = "QMDL filename to convert and download")
|
||||
),
|
||||
summary = "Download a ZIP file",
|
||||
description = "Stream a ZIP file to the client which contains the QMDL file {name} and a PCAP generated from the same file."
|
||||
))]
|
||||
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, compressed) = {
|
||||
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.uncompressed_qmdl_size_bytes == 0 {
|
||||
return Err((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"QMDL file is empty, try again in a bit!".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
(entry_index, entry.compressed)
|
||||
};
|
||||
|
||||
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 extension = if compressed { "qmdl.gz" } else { "qmdl" };
|
||||
let entry = ZipEntryBuilder::new(
|
||||
format!("{qmdl_idx}.{extension}").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 qmdl_store = qmdl_store_lock.read().await;
|
||||
let mut qmdl_reader = qmdl_store.open_entry_qmdl(entry_index).await?;
|
||||
copy(&mut qmdl_reader, &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_store = qmdl_store_lock.read().await;
|
||||
let qmdl_reader = qmdl_store.open_entry_qmdl(entry_index).await?;
|
||||
|
||||
if let Err(e) = generate_pcap_data(&mut entry_writer, qmdl_reader).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_attr(feature = "apidocs", utoipa::path(
|
||||
post,
|
||||
path = "/api/debug/display-state",
|
||||
tag = "Configuration",
|
||||
request_body(
|
||||
content = DisplayState
|
||||
),
|
||||
responses(
|
||||
(status = StatusCode::OK, description = "Display state updated successfully"),
|
||||
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Error sending update to the display"),
|
||||
(status = StatusCode::SERVICE_UNAVAILABLE, description = "Display system not available")
|
||||
),
|
||||
summary = "Set display state",
|
||||
description = "Change the display state (color bar or otherwise) of the device for debugging purposes."
|
||||
))]
|
||||
pub async fn debug_set_display_state(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
Json(display_state): Json<DisplayState>,
|
||||
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||
if let Some(ui_sender) = &state.ui_update_sender {
|
||||
ui_sender.send(display_state).await.map_err(|_| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"failed to send display state update".to_string(),
|
||||
)
|
||||
})?;
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
"display state updated successfully".to_string(),
|
||||
))
|
||||
} else {
|
||||
Err((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"display system not available".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_zip::base::read::mem::ZipFileReader;
|
||||
use axum::extract::{Path, State};
|
||||
use tempfile::TempDir;
|
||||
|
||||
async fn create_test_qmdl_store() -> (TempDir, Arc<RwLock<crate::qmdl_store::RecordingStore>>) {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let store_path = temp_dir.path().to_path_buf();
|
||||
let store = crate::qmdl_store::RecordingStore::create(&store_path)
|
||||
.await
|
||||
.unwrap();
|
||||
(temp_dir, Arc::new(RwLock::new(store)))
|
||||
}
|
||||
|
||||
async fn create_test_entry_with_data(
|
||||
store_lock: &Arc<RwLock<crate::qmdl_store::RecordingStore>>,
|
||||
test_data: &[u8],
|
||||
) -> String {
|
||||
let entry_name = {
|
||||
let mut store = store_lock.write().await;
|
||||
let (mut qmdl_file, _analysis_file) = store.new_entry().await.unwrap();
|
||||
|
||||
if !test_data.is_empty() {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
qmdl_file.write_all(test_data).await.unwrap();
|
||||
qmdl_file.flush().await.unwrap();
|
||||
}
|
||||
|
||||
let current_entry = store.current_entry.unwrap();
|
||||
let entry = &store.manifest.entries[current_entry];
|
||||
let entry_name = entry.name.clone();
|
||||
|
||||
store
|
||||
.update_entry_qmdl_size(current_entry, test_data.len())
|
||||
.await
|
||||
.unwrap();
|
||||
entry_name
|
||||
};
|
||||
|
||||
let mut store = store_lock.write().await;
|
||||
store.close_current_entry().await.unwrap();
|
||||
entry_name
|
||||
}
|
||||
|
||||
fn create_test_server_state(
|
||||
store_lock: Arc<RwLock<crate::qmdl_store::RecordingStore>>,
|
||||
) -> Arc<ServerState> {
|
||||
let (tx, _rx) = tokio::sync::mpsc::channel(1);
|
||||
let (analysis_tx, _analysis_rx) = tokio::sync::mpsc::channel(1);
|
||||
|
||||
let analysis_status = {
|
||||
let store = store_lock.try_read().unwrap();
|
||||
crate::analysis::AnalysisStatus::new(&store)
|
||||
};
|
||||
|
||||
Arc::new(ServerState {
|
||||
config_path: "/tmp/test_config.toml".to_string(),
|
||||
config: Config::default(),
|
||||
qmdl_store_lock: store_lock,
|
||||
diag_device_ctrl_sender: tx,
|
||||
analysis_status_lock: Arc::new(RwLock::new(analysis_status)),
|
||||
analysis_sender: analysis_tx,
|
||||
daemon_restart_token: CancellationToken::new(),
|
||||
ui_update_sender: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_zip_success() {
|
||||
let (_temp_dir, store_lock) = create_test_qmdl_store().await;
|
||||
let test_qmdl_data = vec![0x7E, 0x00, 0x00, 0x00, 0x10, 0x00, 0x7E];
|
||||
let entry_name = create_test_entry_with_data(&store_lock, &test_qmdl_data).await;
|
||||
let state = create_test_server_state(store_lock);
|
||||
|
||||
let result = get_zip(State(state), Path(entry_name.clone())).await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let response = result.unwrap();
|
||||
|
||||
let headers = response.headers();
|
||||
assert_eq!(headers.get("content-type").unwrap(), "application/zip");
|
||||
|
||||
let body = response.into_body();
|
||||
let body_bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
|
||||
|
||||
let zip_reader = ZipFileReader::new(body_bytes.to_vec()).await.unwrap();
|
||||
|
||||
let filenames = zip_reader
|
||||
.file()
|
||||
.entries()
|
||||
.iter()
|
||||
.map(|entry| entry.filename().as_str().unwrap().to_owned())
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
assert_eq!(
|
||||
filenames,
|
||||
vec![
|
||||
format!("{entry_name}.qmdl.gz"),
|
||||
format!("{entry_name}.pcapng"),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
237
daemon/src/stats.rs
Normal file
237
daemon/src/stats.rs
Normal file
@@ -0,0 +1,237 @@
|
||||
use std::ffi::CString;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::battery::get_battery_status;
|
||||
use crate::error::RayhunterError;
|
||||
use crate::server::ServerState;
|
||||
use crate::{battery::BatteryState, qmdl_store::ManifestEntry};
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use log::error;
|
||||
use rayhunter::{Device, util::RuntimeMetadata};
|
||||
use serde::Serialize;
|
||||
use tokio::process::Command;
|
||||
|
||||
/// Structure of device system statistics
|
||||
#[derive(Debug, Serialize)]
|
||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
||||
pub struct SystemStats {
|
||||
pub disk_stats: DiskStats,
|
||||
pub memory_stats: MemoryStats,
|
||||
pub runtime_metadata: RuntimeMetadata,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub battery_status: Option<BatteryState>,
|
||||
}
|
||||
|
||||
impl SystemStats {
|
||||
pub async fn new(qmdl_path: &str, device: &Device) -> Result<Self, String> {
|
||||
Ok(Self {
|
||||
disk_stats: DiskStats::new(qmdl_path)?,
|
||||
memory_stats: MemoryStats::new(device).await?,
|
||||
runtime_metadata: RuntimeMetadata::new(),
|
||||
battery_status: match get_battery_status(device).await {
|
||||
Ok(status) => Some(status),
|
||||
Err(RayhunterError::FunctionNotSupportedForDeviceError) => None,
|
||||
Err(err) => {
|
||||
log::error!("Failed to get battery status: {err}");
|
||||
None
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Device storage information
|
||||
#[derive(Debug, Serialize)]
|
||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
||||
pub struct DiskStats {
|
||||
/// The partition to which the daemon is installed
|
||||
partition: String,
|
||||
/// The total disk size of the partition
|
||||
total_size: String,
|
||||
/// Total used size of the partition
|
||||
used_size: String,
|
||||
/// Remaining free space of the partition
|
||||
available_size: String,
|
||||
/// Disk usage displayed as percentage
|
||||
used_percent: String,
|
||||
/// The root folder to which the partition is mounted
|
||||
mounted_on: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub available_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
impl DiskStats {
|
||||
#[allow(clippy::unnecessary_cast)] // c_ulong is u32 on ARM, u64 on macOS
|
||||
pub fn new(qmdl_path: &str) -> Result<Self, String> {
|
||||
let c_path =
|
||||
CString::new(qmdl_path).map_err(|e| format!("invalid path {qmdl_path}: {e}"))?;
|
||||
let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
|
||||
if unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) } != 0 {
|
||||
return Err(format!(
|
||||
"statvfs({qmdl_path}) failed: {}",
|
||||
std::io::Error::last_os_error()
|
||||
));
|
||||
}
|
||||
|
||||
let block_size = stat.f_frsize as u64;
|
||||
let total_kb = (stat.f_blocks as u64 * block_size / 1024) as usize;
|
||||
let free_kb = (stat.f_bfree as u64 * block_size / 1024) as usize;
|
||||
let available_kb = (stat.f_bavail as u64 * block_size / 1024) as usize;
|
||||
let used_kb = total_kb.saturating_sub(free_kb);
|
||||
let used_percent = if stat.f_blocks > 0 {
|
||||
format!("{}%", (stat.f_blocks - stat.f_bfree) * 100 / stat.f_blocks)
|
||||
} else {
|
||||
"0%".to_string()
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
partition: qmdl_path.to_string(),
|
||||
total_size: humanize_kb(total_kb),
|
||||
used_size: humanize_kb(used_kb),
|
||||
available_size: humanize_kb(available_kb),
|
||||
used_percent,
|
||||
mounted_on: qmdl_path.to_string(),
|
||||
available_bytes: Some(stat.f_bavail as u64 * block_size),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Device memory information
|
||||
#[derive(Debug, Serialize)]
|
||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
||||
pub struct MemoryStats {
|
||||
/// The total memory available on the device
|
||||
total: String,
|
||||
/// The currently used memory
|
||||
used: String,
|
||||
/// Remaining free memory
|
||||
free: String,
|
||||
}
|
||||
|
||||
// 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
|
||||
.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()
|
||||
));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
}
|
||||
|
||||
impl MemoryStats {
|
||||
// runs "free -k" and parses the output to retrieve memory stats for most devices,
|
||||
pub async fn new(device: &Device) -> Result<Self, String> {
|
||||
// Use busybox for Uz801
|
||||
let mut free_cmd: Command;
|
||||
if matches!(device, Device::Uz801) {
|
||||
free_cmd = Command::new("busybox");
|
||||
free_cmd.arg("free");
|
||||
} else {
|
||||
free_cmd = Command::new("free");
|
||||
}
|
||||
free_cmd.arg("-k");
|
||||
let stdout = get_cmd_output(free_cmd).await?;
|
||||
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")?),
|
||||
used: humanize_kb(numbers.next().ok_or("error parsing free output")?),
|
||||
free: humanize_kb(numbers.next().ok_or("error parsing free output")?),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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!("{kb}K");
|
||||
}
|
||||
format!("{:.1}M", kb as f64 / 1024.0)
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
get,
|
||||
path = "/api/system-stats",
|
||||
tag = "Statistics",
|
||||
responses(
|
||||
(status = StatusCode::OK, description = "Success", body = SystemStats),
|
||||
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Error collecting statistics")
|
||||
),
|
||||
summary = "Get system info",
|
||||
description = "Display system/device statistics."
|
||||
))]
|
||||
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(), &state.config.device).await {
|
||||
Ok(stats) => Ok(Json(stats)),
|
||||
Err(err) => {
|
||||
error!("error getting system stats: {err}");
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"error getting system stats".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// QMDL manifest information
|
||||
#[derive(Serialize)]
|
||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
||||
pub struct ManifestStats {
|
||||
/// A vector containing the names of the QMDL files
|
||||
pub entries: Vec<ManifestEntry>,
|
||||
/// The currently open QMDL file
|
||||
pub current_entry: Option<ManifestEntry>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
get,
|
||||
path = "/api/qmdl-manifest",
|
||||
tag = "Statistics",
|
||||
responses(
|
||||
(status = StatusCode::OK, description = "Success", body = ManifestStats)
|
||||
),
|
||||
summary = "QMDL Manifest",
|
||||
description = "List QMDL files available on the device and some of their basic statistics."
|
||||
))]
|
||||
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));
|
||||
Ok(Json(ManifestStats {
|
||||
entries,
|
||||
current_entry,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
get,
|
||||
path = "/api/log",
|
||||
tag = "Statistics",
|
||||
responses(
|
||||
(status = StatusCode::OK, description = "Success", content_type = "text/plain"),
|
||||
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Could not read /data/rayhunter/rayhunter.log file")
|
||||
),
|
||||
summary = "Display log",
|
||||
description = "Download the current device log in UTF-8 plaintext."
|
||||
))]
|
||||
pub async fn get_log() -> Result<String, (StatusCode, String)> {
|
||||
tokio::fs::read_to_string("/data/rayhunter/rayhunter.log")
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
|
||||
}
|
||||
21
daemon/web/.gitignore
vendored
Normal file
21
daemon/web/.gitignore
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
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-*
|
||||
1
daemon/web/.npmrc
Normal file
1
daemon/web/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
7
daemon/web/.prettierignore
Normal file
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
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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
53
daemon/web/eslint.config.js
Normal file
53
daemon/web/eslint.config.js
Normal file
@@ -0,0 +1,53 @@
|
||||
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',
|
||||
'@typescript-eslint/naming-convention': [
|
||||
'error',
|
||||
{
|
||||
selector: 'function',
|
||||
format: ['snake_case'],
|
||||
},
|
||||
{
|
||||
selector: 'method',
|
||||
format: ['snake_case'],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
5146
daemon/web/package-lock.json
generated
Normal file
5146
daemon/web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
daemon/web/package.json
Normal file
39
daemon/web/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"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.53.4",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@types/eslint": "^9.6.0",
|
||||
"@types/node": "^24.7.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.53.7",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^3.4.9",
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"vite": "^7.1.11",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
6
daemon/web/postcss.config.js
Normal file
6
daemon/web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
3
daemon/web/src/app.css
Normal file
3
daemon/web/src/app.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@import 'tailwindcss/base';
|
||||
@import 'tailwindcss/components';
|
||||
@import 'tailwindcss/utilities';
|
||||
13
daemon/web/src/app.d.ts
vendored
Normal file
13
daemon/web/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
12
daemon/web/src/app.html
Normal file
12
daemon/web/src/app.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover" style="width: 100%">
|
||||
<div style="display: contents" class="m-4 xl:m-8">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
24
daemon/web/src/lib/action_errors.svelte.ts
Normal file
24
daemon/web/src/lib/action_errors.svelte.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export class ActionError extends Error {
|
||||
// The number of this an identical error has happened.
|
||||
// This is shown as a number next to the error in the UI.
|
||||
times = $state(1);
|
||||
|
||||
constructor(message: string, cause: Error) {
|
||||
super(message);
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
|
||||
export const action_errors: ActionError[] = $state([]);
|
||||
|
||||
export function add_error(e: Error, msg: string): void {
|
||||
for (const existing of action_errors) {
|
||||
if (existing.message === msg) {
|
||||
existing.times += 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
const action_error = new ActionError(msg, e);
|
||||
action_errors.unshift(action_error);
|
||||
console.log(action_errors.length);
|
||||
}
|
||||
66
daemon/web/src/lib/analysis.svelte.spec.ts
Normal file
66
daemon/web/src/lib/analysis.svelte.spec.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AnalysisRowType, parse_finished_report } from './analysis.svelte';
|
||||
import { type NewlineDeliminatedJson } from './ndjson';
|
||||
|
||||
const SAMPLE_V2_REPORT_NDJSON: NewlineDeliminatedJson = [
|
||||
{
|
||||
analyzers: [
|
||||
{
|
||||
name: 'Analyzer 1',
|
||||
description: 'A first analyzer',
|
||||
version: 2,
|
||||
},
|
||||
{
|
||||
name: 'Analyzer 2',
|
||||
description: 'A second analyzer',
|
||||
version: 2,
|
||||
},
|
||||
],
|
||||
report_version: 2,
|
||||
},
|
||||
{
|
||||
skipped_message_reason: 'The reason why the message was skipped',
|
||||
},
|
||||
{
|
||||
packet_timestamp: '2024-08-19T03:33:54.318Z',
|
||||
events: [
|
||||
null,
|
||||
{
|
||||
event_type: 'Low',
|
||||
message: 'Something nasty happened',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
describe('analysis report parsing', () => {
|
||||
it('parses v2 example analysis', () => {
|
||||
const report = parse_finished_report(SAMPLE_V2_REPORT_NDJSON);
|
||||
expect(report.metadata.report_version).toEqual(2);
|
||||
expect(report.metadata.analyzers).toEqual([
|
||||
{
|
||||
name: 'Analyzer 1',
|
||||
description: 'A first analyzer',
|
||||
version: 2,
|
||||
},
|
||||
{
|
||||
name: 'Analyzer 2',
|
||||
description: 'A second analyzer',
|
||||
version: 2,
|
||||
},
|
||||
]);
|
||||
expect(report.rows).toHaveLength(2);
|
||||
expect(report.rows[0].type).toBe(AnalysisRowType.Skipped);
|
||||
if (report.rows[1].type === AnalysisRowType.Analysis) {
|
||||
const row = report.rows[1];
|
||||
expect(row.events).toHaveLength(2);
|
||||
expect(row.events[0]).toBeNull();
|
||||
const event = row.events[1];
|
||||
const expected_timestamp = new Date('2024-08-19T03:33:54.318Z');
|
||||
expect(row.packet_timestamp.getTime()).toEqual(expected_timestamp.getTime());
|
||||
expect(event!.event_type).toEqual('Low');
|
||||
} else {
|
||||
throw 'wrong row type';
|
||||
}
|
||||
});
|
||||
});
|
||||
138
daemon/web/src/lib/analysis.svelte.ts
Normal file
138
daemon/web/src/lib/analysis.svelte.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { parse_ndjson, type NewlineDeliminatedJson } from './ndjson';
|
||||
import { req } from './utils.svelte';
|
||||
|
||||
export type AnalysisReport = {
|
||||
metadata: ReportMetadata;
|
||||
rows: AnalysisRow[];
|
||||
statistics: ReportStatistics;
|
||||
};
|
||||
|
||||
export type ReportStatistics = {
|
||||
num_warnings: number;
|
||||
num_informational_logs: number;
|
||||
num_skipped_packets: number;
|
||||
};
|
||||
|
||||
export class ReportMetadata {
|
||||
public analyzers: AnalyzerMetadata[];
|
||||
public rayhunter: RayhunterMetadata;
|
||||
public report_version: number;
|
||||
|
||||
constructor(ndjson: any) {
|
||||
this.analyzers = ndjson.analyzers;
|
||||
this.rayhunter = ndjson.rayhunter;
|
||||
this.report_version = ndjson.report_version || 2; // Default to v2
|
||||
}
|
||||
}
|
||||
|
||||
export type RayhunterMetadata = {
|
||||
rayhunter_version: string;
|
||||
system_os: string;
|
||||
arch: string;
|
||||
};
|
||||
|
||||
export type AnalyzerMetadata = {
|
||||
name: string;
|
||||
description: string;
|
||||
version: number;
|
||||
};
|
||||
|
||||
export type AnalysisRow = SkippedPacket | PacketAnalysis;
|
||||
export enum AnalysisRowType {
|
||||
Skipped,
|
||||
Analysis,
|
||||
}
|
||||
|
||||
export type SkippedPacket = {
|
||||
type: AnalysisRowType.Skipped;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type PacketAnalysis = {
|
||||
type: AnalysisRowType.Analysis;
|
||||
packet_timestamp: Date;
|
||||
events: Event[];
|
||||
};
|
||||
|
||||
export type EventType = 'Informational' | 'Low' | 'Medium' | 'High';
|
||||
|
||||
export type Event = {
|
||||
event_type: EventType;
|
||||
message: string;
|
||||
} | null;
|
||||
|
||||
function get_event(event_json: any): Event {
|
||||
if (!['Informational', 'Low', 'Medium', 'High'].includes(event_json.event_type)) {
|
||||
throw `Invalid/unhandled event type: ${event_json.event_type}`;
|
||||
}
|
||||
|
||||
return event_json;
|
||||
}
|
||||
|
||||
function get_rows(row_jsons: any[]): AnalysisRow[] {
|
||||
const rows: AnalysisRow[] = [];
|
||||
for (const row_json of row_jsons) {
|
||||
if (row_json.skipped_message_reason) {
|
||||
rows.push({
|
||||
type: AnalysisRowType.Skipped,
|
||||
reason: row_json.skipped_message_reason,
|
||||
});
|
||||
} else {
|
||||
const events: Event[] = row_json.events.map((event_json: any): Event | null => {
|
||||
if (event_json === null) {
|
||||
return null;
|
||||
} else {
|
||||
return get_event(event_json);
|
||||
}
|
||||
});
|
||||
rows.push({
|
||||
type: AnalysisRowType.Analysis,
|
||||
packet_timestamp: new Date(row_json.packet_timestamp),
|
||||
events,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function get_report_stats(rows: AnalysisRow[]): ReportStatistics {
|
||||
let num_warnings = 0;
|
||||
let num_informational_logs = 0;
|
||||
let num_skipped_packets = 0;
|
||||
for (const row of rows) {
|
||||
if (row.type === AnalysisRowType.Skipped) {
|
||||
num_skipped_packets++;
|
||||
} else {
|
||||
for (const event of row.events) {
|
||||
if (event !== null) {
|
||||
if (event.event_type === 'Informational') {
|
||||
num_informational_logs++;
|
||||
} else {
|
||||
num_warnings++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
num_warnings,
|
||||
num_informational_logs,
|
||||
num_skipped_packets,
|
||||
};
|
||||
}
|
||||
|
||||
export function parse_finished_report(report_json: NewlineDeliminatedJson): AnalysisReport {
|
||||
const metadata = new ReportMetadata(report_json[0]);
|
||||
const rows = get_rows(report_json.slice(1));
|
||||
const statistics = get_report_stats(rows);
|
||||
return {
|
||||
statistics,
|
||||
metadata,
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
export async function get_report(name: string): Promise<AnalysisReport> {
|
||||
const report_json = parse_ndjson(await req('GET', `/api/analysis-report/${name}`));
|
||||
return parse_finished_report(report_json);
|
||||
}
|
||||
62
daemon/web/src/lib/analysisManager.svelte.ts
Normal file
62
daemon/web/src/lib/analysisManager.svelte.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
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> = $state(new Map());
|
||||
public reports: Map<string, AnalysisReport | string> = $state(new Map());
|
||||
public set_queued_status(name: string) {
|
||||
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}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
100
daemon/web/src/lib/components/ActionErrors.svelte
Normal file
100
daemon/web/src/lib/components/ActionErrors.svelte
Normal file
@@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
import { action_errors } from '../action_errors.svelte';
|
||||
|
||||
let pos = $state(0);
|
||||
let current_error = $derived(action_errors[pos]);
|
||||
|
||||
function prev_error() {
|
||||
if (pos > 0) pos -= 1;
|
||||
else pos = action_errors.length - 1;
|
||||
}
|
||||
function next_error() {
|
||||
if (pos + 1 < action_errors.length) pos += 1;
|
||||
else pos = 0;
|
||||
}
|
||||
function clear_errors() {
|
||||
pos = 0;
|
||||
action_errors.length = 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if action_errors.length > 0}
|
||||
<div
|
||||
class="bg-red-100 border-red-100 drop-shadow p-4 flex flex-col gap-2
|
||||
border rounded-md flex-1 justify-between fixed z-10 right-3 bottom-3 ml-3"
|
||||
>
|
||||
<div class="flex flex-row justify-between">
|
||||
<span class="text-xl font-bold mb-2 mr-5 flex flex-row items-center gap-1 text-red-600">
|
||||
<svg
|
||||
class="w-6 h-6 text-red-600"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm11-4a1 1 0 1 0-2 0v5a1 1 0 1 0 2 0V8Zm-1 7a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H12Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Error Completing Action {current_error.times > 1 ? `x${current_error.times}` : ''}
|
||||
</span>
|
||||
<div class="flex items-center mb-2">
|
||||
{#if action_errors.length > 1}
|
||||
<span>{pos + 1}/{action_errors.length}</span>
|
||||
<button title="previous error" aria-label="previous error" onclick={prev_error}>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="m 15.499979,19.499979 -6.9999997,-7 6.9999997,-6.9999997"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button title="next error" aria-label="next error" onclick={next_error}>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="m 8.5000207,5.4999793 7.0000003,6.9999997 -7.0000003,7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<button title="clear errors" aria-label="clear errors" onclick={clear_errors}>
|
||||
<svg style="width:24px;height:24px" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span>{current_error.message}</span>
|
||||
{#if current_error.cause}
|
||||
<details>
|
||||
<summary>Details</summary>
|
||||
<code>{current_error.cause}</code>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
92
daemon/web/src/lib/components/AnalysisStatus.svelte
Normal file
92
daemon/web/src/lib/components/AnalysisStatus.svelte
Normal file
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
import { AnalysisStatus } from '$lib/analysisManager.svelte';
|
||||
import type { ManifestEntry } from '$lib/manifest.svelte';
|
||||
let {
|
||||
entry,
|
||||
onclick,
|
||||
analysis_visible,
|
||||
}: {
|
||||
entry: ManifestEntry;
|
||||
onclick: () => void;
|
||||
analysis_visible: boolean;
|
||||
} = $props();
|
||||
|
||||
let summary = $derived.by(() => {
|
||||
if (entry.analysis_status === AnalysisStatus.Queued) {
|
||||
return 'Queued...';
|
||||
} else if (entry.analysis_status === AnalysisStatus.Running) {
|
||||
return 'Running...';
|
||||
} else if (entry.analysis_status === AnalysisStatus.Finished) {
|
||||
if (entry.analysis_report === undefined) {
|
||||
return 'Loading...';
|
||||
} else if (typeof entry.analysis_report === 'string') {
|
||||
return entry.analysis_report;
|
||||
} else {
|
||||
return `${entry.analysis_report.statistics.num_warnings} warnings`;
|
||||
}
|
||||
} else {
|
||||
return 'Loading...';
|
||||
}
|
||||
});
|
||||
|
||||
let ready = $derived.by(() => {
|
||||
let finished = entry.analysis_status === AnalysisStatus.Finished;
|
||||
let report_available = entry.analysis_report !== undefined;
|
||||
return finished && report_available;
|
||||
});
|
||||
|
||||
let button_class = $derived.by(() => {
|
||||
if (!ready) {
|
||||
return 'text-gray-700';
|
||||
} else if ((entry.get_num_warnings() || 0) < 1) {
|
||||
return 'text-green-700 border-green-500 bg-green-200 text-blue-600 border rounded-full px-2';
|
||||
} else {
|
||||
return 'text-red-700 border-red-500 bg-red-200 text-blue-600 border rounded-full px-2';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<button class="flex flex-row gap-1 lg:gap-2" disabled={!ready} {onclick}>
|
||||
<span class="flex flex-row items-center gap-1">
|
||||
{#if entry.analysis_status === AnalysisStatus.Queued || entry.analysis_status === AnalysisStatus.Running || (entry.analysis_status === AnalysisStatus.Finished && entry.analysis_report === undefined)}
|
||||
<svg
|
||||
class="animate-spin h-4 w-4 text-blue-600"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{/if}
|
||||
<span class={button_class}>{summary}</span>
|
||||
</span>
|
||||
<svg
|
||||
class="w-6 h-6 text-gray-800 transition-transform {analysis_visible ? 'rotate-180' : ''}"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="m19 9-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
103
daemon/web/src/lib/components/AnalysisTable.svelte
Normal file
103
daemon/web/src/lib/components/AnalysisTable.svelte
Normal file
@@ -0,0 +1,103 @@
|
||||
<script lang="ts">
|
||||
import { AnalysisRowType, type AnalysisReport } from '$lib/analysis.svelte';
|
||||
let {
|
||||
report,
|
||||
}: {
|
||||
report: AnalysisReport;
|
||||
} = $props();
|
||||
|
||||
const date_formatter = new Intl.DateTimeFormat(undefined, {
|
||||
timeStyle: 'long',
|
||||
dateStyle: 'short',
|
||||
});
|
||||
|
||||
const analyzers = $derived(report.metadata.analyzers);
|
||||
|
||||
const skipped_messages: Map<string, number> = $derived.by(() => {
|
||||
let map = new Map();
|
||||
for (const row of report.rows) {
|
||||
if (row.type === AnalysisRowType.Skipped) {
|
||||
let count = map.get(row.reason);
|
||||
if (count === undefined) {
|
||||
count = 0;
|
||||
}
|
||||
map.set(row.reason, count + 1);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<p class="text-lg underline">Warnings and Informational Logs</p>
|
||||
{#if report.statistics.num_warnings === 0 && report.statistics.num_informational_logs === 0}
|
||||
<p>Nothing to show!</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table-auto text-left">
|
||||
<thead class="p-2">
|
||||
<tr class="bg-gray-300">
|
||||
<th class="p-2">Timestamp</th>
|
||||
<th class="p-2">Heuristic</th>
|
||||
<th class="p-2">Warning</th>
|
||||
<th class="p-2">Severity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each report.rows as row}
|
||||
{#if row.type === AnalysisRowType.Analysis}
|
||||
{@const parsed_date = new Date(row.packet_timestamp)}
|
||||
{#each row.events as event, analyzerIndex}
|
||||
{#if event !== null}
|
||||
{@const analyzer = analyzers[analyzerIndex]}
|
||||
{@const event_type_class = {
|
||||
Informational: '',
|
||||
Low: 'bg-yellow-200',
|
||||
Medium: 'bg-orange-400',
|
||||
High: 'bg-red-600',
|
||||
}[event.event_type]}
|
||||
<tr class="even:bg-gray-200 odd:bg-white">
|
||||
<td class="p-2">{date_formatter.format(parsed_date)}</td>
|
||||
<td class="p-2">{analyzer.name} v{analyzer.version}</td>
|
||||
<td class="p-2">{event.message}</td>
|
||||
<td class="p-2 {event_type_class} text-center"
|
||||
>{event.event_type}</td
|
||||
>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if report.statistics.num_skipped_packets > 0}
|
||||
<div>
|
||||
<p class="text-lg underline">Unparsed Messages</p>
|
||||
<p>
|
||||
These are due to a limitation or bug in Rayhunter's parser, and aren't usually a
|
||||
problem. We'll not accept bug reports about them unless something else is going wrong
|
||||
(such as false-positives or definite false-negatives)
|
||||
</p>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table-auto text-left">
|
||||
<thead class="p-2">
|
||||
<tr class="bg-gray-300">
|
||||
<th scope="col" class="p-2">Total Msgs Affected</th>
|
||||
<th scope="col">Reason/Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each skipped_messages.entries() as [message, count]}
|
||||
<tr class="even:bg-gray-200 odd:bg-white">
|
||||
<td class="text-center">{count}</td>
|
||||
<td>{message}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
69
daemon/web/src/lib/components/AnalysisView.svelte
Normal file
69
daemon/web/src/lib/components/AnalysisView.svelte
Normal file
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { type ReportMetadata } from '$lib/analysis.svelte';
|
||||
import type { ManifestEntry } from '$lib/manifest.svelte';
|
||||
import { AnalysisManager } from '$lib/analysisManager.svelte';
|
||||
import AnalysisTable from './AnalysisTable.svelte';
|
||||
import ReAnalyzeButton from './ReAnalyzeButton.svelte';
|
||||
let {
|
||||
entry,
|
||||
manager,
|
||||
current,
|
||||
}: {
|
||||
entry: ManifestEntry;
|
||||
manager: AnalysisManager;
|
||||
current: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="container mt-2">
|
||||
{#if entry.analysis_report === undefined}
|
||||
<p>Report unavailable, try refreshing.</p>
|
||||
{:else if typeof entry.analysis_report === 'string'}
|
||||
<p>Error getting analysis report: {entry.analysis_report}</p>
|
||||
{:else}
|
||||
{@const metadata: ReportMetadata = entry.analysis_report.metadata}
|
||||
{@const numWarnings: number = entry.get_num_warnings() || 0}
|
||||
<div class="flex flex-col gap-2">
|
||||
{#if !!numWarnings || !current}
|
||||
<div class="flex flex-row justify-between items-center">
|
||||
{#if !!numWarnings}
|
||||
<div
|
||||
class="text-red-700 border-red-500 border rounded-lg text-blue-600 px-2 py-1 mr-12"
|
||||
>
|
||||
Your Rayhunter device raised {`${numWarnings}`} warning{`${
|
||||
numWarnings > 1 ? 's' : ''
|
||||
}`}!
|
||||
<a
|
||||
href="https://efforg.github.io/rayhunter/faq.html#red"
|
||||
class="text-blue-600 underline">Read the FAQ</a
|
||||
> to learn what you can do about it
|
||||
</div>
|
||||
{/if}
|
||||
{#if !current}
|
||||
<ReAnalyzeButton {entry} {manager} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if entry.analysis_report.rows.length > 0}
|
||||
<AnalysisTable report={entry.analysis_report} />
|
||||
{:else}
|
||||
<p>No warnings to display!</p>
|
||||
{/if}
|
||||
{#if metadata !== undefined && metadata.rayhunter !== undefined}
|
||||
<div>
|
||||
<p class="text-lg underline">Metadata</p>
|
||||
<p>Analysis by Rayhunter version {metadata.rayhunter.rayhunter_version}</p>
|
||||
<p><b>Device system OS:</b> {metadata.rayhunter.system_os}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-lg underline">Analyzers</p>
|
||||
{#each metadata.analyzers as analyzer}
|
||||
<p><b>{analyzer.name}:</b> {analyzer.description}</p>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p>N/A (analysis generated by an older version of rayhunter)</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
100
daemon/web/src/lib/components/ApiRequestButton.svelte
Normal file
100
daemon/web/src/lib/components/ApiRequestButton.svelte
Normal file
@@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
import { user_action_req } from '$lib/utils.svelte';
|
||||
|
||||
let {
|
||||
url,
|
||||
method = 'POST',
|
||||
label,
|
||||
loadingLabel,
|
||||
disabled = false,
|
||||
variant = 'blue',
|
||||
icon,
|
||||
onclick,
|
||||
ariaLabel,
|
||||
errorMessage,
|
||||
jsonBody,
|
||||
}: {
|
||||
url: string;
|
||||
method?: string;
|
||||
label: string;
|
||||
loadingLabel?: string;
|
||||
disabled?: boolean;
|
||||
variant?: 'blue' | 'red' | 'green';
|
||||
icon?: any; // Svelte snippet
|
||||
onclick?: () => void | Promise<void>;
|
||||
ariaLabel?: string;
|
||||
errorMessage?: string;
|
||||
jsonBody?: unknown;
|
||||
} = $props();
|
||||
|
||||
let is_requesting = $state(false);
|
||||
let is_disabled = $derived(disabled || is_requesting);
|
||||
|
||||
const variantClasses = {
|
||||
blue: {
|
||||
enabled: 'bg-blue-500 hover:bg-blue-700',
|
||||
disabled: 'bg-blue-500 opacity-50 cursor-not-allowed',
|
||||
},
|
||||
red: {
|
||||
enabled: 'bg-red-500 hover:bg-red-700',
|
||||
disabled: 'bg-red-500 opacity-50 cursor-not-allowed',
|
||||
},
|
||||
green: {
|
||||
enabled: 'bg-green-500 hover:bg-green-700',
|
||||
disabled: 'bg-green-500 opacity-50 cursor-not-allowed',
|
||||
},
|
||||
};
|
||||
|
||||
async function handle_click() {
|
||||
if (is_disabled) return;
|
||||
|
||||
is_requesting = true;
|
||||
try {
|
||||
await user_action_req(
|
||||
method,
|
||||
url,
|
||||
errorMessage ? errorMessage : 'Error performing action',
|
||||
jsonBody
|
||||
);
|
||||
if (onclick) {
|
||||
await onclick();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to ${method} ${url}:`, err);
|
||||
alert(`Request failed. Please try again.`);
|
||||
} finally {
|
||||
is_requesting = false;
|
||||
}
|
||||
}
|
||||
|
||||
let buttonClasses = $derived(
|
||||
is_disabled ? variantClasses[variant].disabled : variantClasses[variant].enabled
|
||||
);
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="text-white font-bold py-2 px-2 sm:px-4 rounded-md flex flex-row items-center gap-1 {buttonClasses}"
|
||||
onclick={handle_click}
|
||||
disabled={is_disabled}
|
||||
aria-label={ariaLabel || label}
|
||||
>
|
||||
<span>{is_requesting && loadingLabel ? loadingLabel : label}</span>
|
||||
{#if is_requesting}
|
||||
<svg
|
||||
class="w-4 h-4 text-white animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{:else if icon}
|
||||
{@render icon()}
|
||||
{/if}
|
||||
</button>
|
||||
121
daemon/web/src/lib/components/ClockDriftAlert.svelte
Normal file
121
daemon/web/src/lib/components/ClockDriftAlert.svelte
Normal file
@@ -0,0 +1,121 @@
|
||||
<script lang="ts">
|
||||
import { get_daemon_time } from '$lib/utils.svelte';
|
||||
import ApiRequestButton from './ApiRequestButton.svelte';
|
||||
|
||||
let show_alert = $state(false);
|
||||
let device_system_time = $state('');
|
||||
let device_adjusted_time = $state('');
|
||||
let browser_time = $state('');
|
||||
let has_offset = $state(false);
|
||||
let computed_offset = $state(0);
|
||||
let dismissed = $state(false);
|
||||
let check_completed = $state(false);
|
||||
|
||||
const DRIFT_THRESHOLD_SECONDS = 30;
|
||||
|
||||
function format_time(date: Date): string {
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
async function check_clock_drift() {
|
||||
if (check_completed) return;
|
||||
|
||||
try {
|
||||
const daemon_time_response = await get_daemon_time();
|
||||
const browser_now = new Date();
|
||||
const daemon_system_ms = new Date(daemon_time_response.system_time).getTime();
|
||||
const device_adjusted_ms = new Date(daemon_time_response.adjusted_time).getTime();
|
||||
const drift_seconds = Math.round((browser_now.getTime() - device_adjusted_ms) / 1000);
|
||||
|
||||
if (Math.abs(drift_seconds) > DRIFT_THRESHOLD_SECONDS && !dismissed) {
|
||||
device_system_time = format_time(new Date(daemon_time_response.system_time));
|
||||
device_adjusted_time = format_time(new Date(daemon_time_response.adjusted_time));
|
||||
browser_time = format_time(browser_now);
|
||||
has_offset = daemon_time_response.offset_seconds !== 0;
|
||||
// Calculate offset needed: browser_time - daemon_system_time
|
||||
computed_offset = Math.round((browser_now.getTime() - daemon_system_ms) / 1000);
|
||||
show_alert = true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to check clock drift:', err);
|
||||
}
|
||||
check_completed = true;
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
show_alert = false;
|
||||
dismissed = true;
|
||||
}
|
||||
|
||||
// Check clock drift on component mount
|
||||
$effect(() => {
|
||||
check_clock_drift();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if show_alert}
|
||||
<div
|
||||
class="bg-yellow-100 border-yellow-400 drop-shadow p-4 flex flex-col gap-2 border rounded-md"
|
||||
>
|
||||
<span class="text-xl font-bold flex flex-row items-center gap-2 text-yellow-700">
|
||||
<svg
|
||||
class="w-6 h-6 text-yellow-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 0v4a1 1 0 0 0 .293.707l3 3a1 1 0 0 0 1.414-1.414L13 11.586V8Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Clock Mismatch Detected
|
||||
</span>
|
||||
<p>
|
||||
Rayhunter's clock doesn't match your browser's, and may be incorrect. This can happen if
|
||||
Rayhunter is unable to get the correct time from the internet. Consider synchronizing
|
||||
your browser's clock with the button below, or using another SIM card for better
|
||||
results.
|
||||
</p>
|
||||
<table class="w-fit">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="pr-2">Rayhunter clock (system):</td>
|
||||
<td class="font-mono">{device_system_time}</td>
|
||||
</tr>
|
||||
{#if has_offset}
|
||||
<tr>
|
||||
<td class="pr-2">Rayhunter clock (adjusted):</td>
|
||||
<td class="font-mono">{device_adjusted_time}</td>
|
||||
</tr>
|
||||
{/if}
|
||||
<tr>
|
||||
<td class="pr-2">Browser clock:</td>
|
||||
<td class="font-mono">{browser_time}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>Copy browser clock to device?</p>
|
||||
<div class="flex flex-row gap-2 justify-end">
|
||||
<button
|
||||
class="font-medium py-2 px-4 rounded-md border border-gray-400 hover:bg-yellow-200"
|
||||
onclick={dismiss}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
<ApiRequestButton
|
||||
url="/api/time-offset"
|
||||
label="Sync Clock"
|
||||
loadingLabel="Syncing..."
|
||||
variant="green"
|
||||
jsonBody={{ offset_seconds: computed_offset }}
|
||||
onclick={dismiss}
|
||||
errorMessage="Error syncing clock"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
426
daemon/web/src/lib/components/ConfigForm.svelte
Normal file
426
daemon/web/src/lib/components/ConfigForm.svelte
Normal file
@@ -0,0 +1,426 @@
|
||||
<script lang="ts">
|
||||
import { get_config, set_config, test_notification, type Config } from '../utils.svelte';
|
||||
import Modal from './Modal.svelte';
|
||||
|
||||
let { shown = $bindable() }: { shown: boolean } = $props();
|
||||
let config = $state<Config | null>(null);
|
||||
|
||||
let loading = $state(false);
|
||||
let saving = $state(false);
|
||||
let testingNotification = $state(false);
|
||||
let message = $state('');
|
||||
let messageType = $state<'success' | 'error' | null>(null);
|
||||
let testMessage = $state('');
|
||||
let testMessageType = $state<'success' | 'error' | null>(null);
|
||||
|
||||
async function load_config() {
|
||||
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 save_config() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async function send_test_notification() {
|
||||
try {
|
||||
testingNotification = true;
|
||||
testMessage = '';
|
||||
testMessageType = null;
|
||||
await test_notification();
|
||||
testMessage = 'Test notification sent successfully!';
|
||||
testMessageType = 'success';
|
||||
} catch (error) {
|
||||
testMessage = `${error}`;
|
||||
testMessageType = 'error';
|
||||
} finally {
|
||||
testingNotification = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (shown && !config) {
|
||||
load_config();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal bind:shown title="Configuration">
|
||||
<div class="p-2">
|
||||
{#if loading}
|
||||
<div class="text-center py-4">Loading config...</div>
|
||||
{:else if config}
|
||||
<form
|
||||
class="space-y-4"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
save_config();
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<option value={4}>4 - High visibility (full screen color)</option>
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Note: Rayhunter draws over the device's native UI, so some flickering is
|
||||
expected
|
||||
</p>
|
||||
</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 new 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 space-y-3">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">Notification Settings</h3>
|
||||
<div>
|
||||
<label for="ntfy_url" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
ntfy URL for Sending Notifications (if unset you will not receive
|
||||
notifications)
|
||||
</label>
|
||||
<input
|
||||
id="ntfy_url"
|
||||
type="url"
|
||||
bind:value={config.ntfy_url}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Test button below uses the saved configuration URL, not the input above
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={send_test_notification}
|
||||
disabled={testingNotification}
|
||||
class="bg-rayhunter-blue hover:bg-rayhunter-dark-blue disabled:opacity-50 disabled:cursor-not-allowed text-white font-bold py-2 px-4 rounded-md flex flex-row gap-1 items-center"
|
||||
>
|
||||
{#if testingNotification}
|
||||
<div
|
||||
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
Sending...
|
||||
{: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="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
|
||||
></path>
|
||||
</svg>
|
||||
Send Test Notification
|
||||
{/if}
|
||||
</button>
|
||||
{#if testMessage}
|
||||
<div
|
||||
class="mt-2 p-2 rounded text-sm {testMessageType === 'error'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-green-100 text-green-700'}"
|
||||
>
|
||||
{testMessage}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Enabled Notification Types
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enable_warning_notifications"
|
||||
value="Warning"
|
||||
bind:group={config.enabled_notifications}
|
||||
/>
|
||||
<label
|
||||
for="enable_warning_notifications"
|
||||
class="ml-2 block text-sm text-gray-700"
|
||||
>
|
||||
Warnings
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enable_lowbattery_notifications"
|
||||
value="LowBattery"
|
||||
bind:group={config.enabled_notifications}
|
||||
/>
|
||||
<label
|
||||
for="enable_lowbattery_notifications"
|
||||
class="ml-2 block text-sm text-gray-700"
|
||||
>
|
||||
Low Battery
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t pt-4 mt-6 space-y-3">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">Storage Management</h3>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="min_space_to_start_recording_mb"
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Minimum Space to Start Recording (MB)
|
||||
</label>
|
||||
<input
|
||||
id="min_space_to_start_recording_mb"
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={config.min_space_to_start_recording_mb}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Recording will not start if less than this amount of disk space is free
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="min_space_to_continue_recording_mb"
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Minimum Space to Continue Recording (MB)
|
||||
</label>
|
||||
<input
|
||||
id="min_space_to_continue_recording_mb"
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={config.min_space_to_continue_recording_mb}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Recording will stop automatically if disk space drops below this level
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t pt-4 mt-6">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">
|
||||
Analyzer Heuristic Settings
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="imsi_requested"
|
||||
type="checkbox"
|
||||
bind:checked={config.analyzers.imsi_requested}
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
|
||||
/>
|
||||
<label for="imsi_requested" class="ml-2 block text-sm text-gray-700">
|
||||
IMSI Requested Heuristic
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="connection_redirect_2g_downgrade"
|
||||
type="checkbox"
|
||||
bind:checked={config.analyzers.connection_redirect_2g_downgrade}
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
|
||||
/>
|
||||
<label
|
||||
for="connection_redirect_2g_downgrade"
|
||||
class="ml-2 block text-sm text-gray-700"
|
||||
>
|
||||
Connection Redirect 2G Downgrade Heuristic
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="lte_sib6_and_7_downgrade"
|
||||
type="checkbox"
|
||||
bind:checked={config.analyzers.lte_sib6_and_7_downgrade}
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
|
||||
/>
|
||||
<label
|
||||
for="lte_sib6_and_7_downgrade"
|
||||
class="ml-2 block text-sm text-gray-700"
|
||||
>
|
||||
LTE SIB6 and SIB7 Downgrade Heuristic
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="null_cipher"
|
||||
type="checkbox"
|
||||
bind:checked={config.analyzers.null_cipher}
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
|
||||
/>
|
||||
<label for="null_cipher" class="ml-2 block text-sm text-gray-700">
|
||||
Null Cipher Heuristic
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="nas_null_cipher"
|
||||
type="checkbox"
|
||||
bind:checked={config.analyzers.nas_null_cipher}
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
|
||||
/>
|
||||
<label for="nas_null_cipher" class="ml-2 block text-sm text-gray-700">
|
||||
NAS Null Cipher Heuristic
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="incomplete_sib"
|
||||
type="checkbox"
|
||||
bind:checked={config.analyzers.incomplete_sib}
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
|
||||
/>
|
||||
<label for="incomplete_sib" class="ml-2 block text-sm text-gray-700">
|
||||
Incomplete SIB Heuristic
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="test_analyzer"
|
||||
type="checkbox"
|
||||
bind:checked={config.analyzers.test_analyzer}
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
|
||||
/>
|
||||
<label for="test_analyzer" class="ml-2 block text-sm text-gray-700">
|
||||
Test Heuristic (noisy!)
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="diagnostic_analyzer"
|
||||
type="checkbox"
|
||||
bind:checked={config.analyzers.diagnostic_analyzer}
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
|
||||
/>
|
||||
<label
|
||||
for="diagnostic_analyzer"
|
||||
class="ml-2 block text-sm text-gray-700"
|
||||
>
|
||||
Diagnostic Analyzer
|
||||
</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}
|
||||
</div>
|
||||
</Modal>
|
||||
12
daemon/web/src/lib/components/DeleteAllButton.svelte
Normal file
12
daemon/web/src/lib/components/DeleteAllButton.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import DeleteButton from './DeleteButton.svelte';
|
||||
</script>
|
||||
|
||||
<div class="flex flex-row justify-end gap-2">
|
||||
<DeleteButton
|
||||
text="Delete ALL Recordings"
|
||||
prompt={`Are you sure you want to delete ALL recordings?`}
|
||||
url={`/api/delete-all-recordings`}
|
||||
name="all recodings"
|
||||
/>
|
||||
</div>
|
||||
34
daemon/web/src/lib/components/DeleteButton.svelte
Normal file
34
daemon/web/src/lib/components/DeleteButton.svelte
Normal file
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { user_action_req } from '$lib/utils.svelte';
|
||||
let {
|
||||
text,
|
||||
url,
|
||||
prompt,
|
||||
name,
|
||||
}: {
|
||||
text?: string;
|
||||
url: string;
|
||||
prompt: string;
|
||||
name: string;
|
||||
} = $props();
|
||||
|
||||
function confirm_delete() {
|
||||
if (window.confirm(prompt)) {
|
||||
user_action_req('POST', url, 'Unable to delete recording ' + name);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-2 sm:px-4 rounded-md flex flex-row"
|
||||
onclick={confirm_delete}
|
||||
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>
|
||||
23
daemon/web/src/lib/components/DownloadLink.svelte
Normal file
23
daemon/web/src/lib/components/DownloadLink.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
url,
|
||||
text,
|
||||
full_button = false,
|
||||
}: {
|
||||
url: string;
|
||||
text: string;
|
||||
full_button?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<a
|
||||
href={url}
|
||||
class="flex flex-row {full_button
|
||||
? 'bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-2 sm:px-4 rounded-md'
|
||||
: 'text-blue-600 underline'}"
|
||||
>
|
||||
{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>
|
||||
</a>
|
||||
28
daemon/web/src/lib/components/LogView.svelte
Normal file
28
daemon/web/src/lib/components/LogView.svelte
Normal file
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { get_logs } from '$lib/utils.svelte';
|
||||
import Modal from './Modal.svelte';
|
||||
|
||||
let { shown = $bindable() }: { shown: boolean } = $props();
|
||||
let content: string | undefined = $state(undefined);
|
||||
|
||||
$effect(() => {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
if (content !== undefined && (document.hidden || !shown)) {
|
||||
return;
|
||||
}
|
||||
content = await get_logs();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal bind:shown title="Logs">
|
||||
<div class="bg-gray-100 border border-gray-100 rounded-md overflow-scroll">
|
||||
<pre class="m-2">{content}</pre>
|
||||
</div>
|
||||
</Modal>
|
||||
106
daemon/web/src/lib/components/ManifestCard.svelte
Normal file
106
daemon/web/src/lib/components/ManifestCard.svelte
Normal file
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import { ManifestEntry } from '$lib/manifest.svelte';
|
||||
import { AnalysisManager } from '$lib/analysisManager.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,
|
||||
manager,
|
||||
}: {
|
||||
entry: ManifestEntry;
|
||||
current: boolean;
|
||||
server_is_recording: boolean;
|
||||
manager: AnalysisManager;
|
||||
} = $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 overflow-x-auto overflow-y-hidden"
|
||||
>
|
||||
{#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>
|
||||
{#if entry.stop_reason}
|
||||
<div class="bg-yellow-50 border border-yellow-300 rounded p-2 text-yellow-800 text-sm">
|
||||
{entry.stop_reason}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-row justify-between lg:justify-end gap-1 mt-2 overflow-x-auto">
|
||||
<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()}
|
||||
name={entry.name}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="border-b {analysis_visible ? '' : 'hidden'}">
|
||||
<AnalysisView {entry} {manager} {current} />
|
||||
</div>
|
||||
</div>
|
||||
42
daemon/web/src/lib/components/ManifestTable.svelte
Normal file
42
daemon/web/src/lib/components/ManifestTable.svelte
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { ManifestEntry } from '$lib/manifest.svelte';
|
||||
import { AnalysisManager } from '$lib/analysisManager.svelte';
|
||||
import { screenIsLgUp } from '$lib/stores/breakpoint';
|
||||
import TableRow from './ManifestTableRow.svelte';
|
||||
import Card from './ManifestCard.svelte';
|
||||
interface Props {
|
||||
entries: ManifestEntry[];
|
||||
server_is_recording: boolean;
|
||||
manager: AnalysisManager;
|
||||
}
|
||||
let { entries, server_is_recording, manager }: Props = $props();
|
||||
</script>
|
||||
|
||||
<!--For larger screens we use a table-->
|
||||
{#if $screenIsLgUp}
|
||||
<table class="table-auto text-left 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">Download</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} {manager} />
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{:else}
|
||||
<!--For smaller screens we use cards-->
|
||||
<div class="flex flex-col gap-4">
|
||||
{#each entries as entry}
|
||||
<Card {entry} current={false} {server_is_recording} {manager} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
72
daemon/web/src/lib/components/ManifestTableRow.svelte
Normal file
72
daemon/web/src/lib/components/ManifestTableRow.svelte
Normal file
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import { ManifestEntry } from '$lib/manifest.svelte';
|
||||
import { AnalysisManager } from '$lib/analysisManager.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,
|
||||
manager,
|
||||
}: {
|
||||
entry: ManifestEntry;
|
||||
current: boolean;
|
||||
i: number;
|
||||
manager: AnalysisManager;
|
||||
} = $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">
|
||||
<div class="flex flex-row gap-2">
|
||||
<DownloadLink url={entry.get_pcap_url()} text="pcap" />
|
||||
<DownloadLink url={entry.get_qmdl_url()} text="qmdl" />
|
||||
<DownloadLink url={entry.get_zip_url()} text="zip" />
|
||||
</div>
|
||||
</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()}
|
||||
name={entry.name}
|
||||
/>
|
||||
</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} {manager} {current} />
|
||||
</td>
|
||||
</tr>
|
||||
62
daemon/web/src/lib/components/Modal.svelte
Normal file
62
daemon/web/src/lib/components/Modal.svelte
Normal file
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let {
|
||||
shown = $bindable(),
|
||||
title,
|
||||
children,
|
||||
}: { shown: boolean; title: string; children: Snippet } = $props();
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener('scroll', () => {
|
||||
document.documentElement.style.setProperty('--scroll-y', `${window.scrollY}px`);
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (shown) {
|
||||
const scrollY = document.documentElement.style.getPropertyValue('--scroll-y');
|
||||
const body = document.body;
|
||||
body.style.position = 'fixed';
|
||||
body.style.top = `-${scrollY}`;
|
||||
} else {
|
||||
const body = document.body;
|
||||
const scrollY = body.style.top;
|
||||
body.style.position = '';
|
||||
body.style.top = '';
|
||||
window.scrollTo(0, parseInt(scrollY || '0') * -1);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if shown}
|
||||
<div
|
||||
class="fixed left-5 right-5 top-5 bottom-5 z-50 bg-white border border-white rounded-md
|
||||
flex flex-col p-2 drop-shadow"
|
||||
>
|
||||
<div class="flex justify-between items-center p-1">
|
||||
<span class="text-2xl">{title}</span>
|
||||
<button onclick={() => (shown = false)} aria-label="close">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M5.29289 5.29289C5.68342 4.90237 6.31658 4.90237 6.70711 5.29289L12 10.5858L17.2929 5.29289C17.6834 4.90237 18.3166 4.90237 18.7071 5.29289C19.0976 5.68342 19.0976 6.31658 18.7071 6.70711L13.4142 12L18.7071 17.2929C19.0976 17.6834 19.0976 18.3166 18.7071 18.7071C18.3166 19.0976 17.6834 19.0976 17.2929 18.7071L12 13.4142L6.70711 18.7071C6.31658 19.0976 5.68342 19.0976 5.29289 18.7071C4.90237 18.3166 4.90237 17.6834 5.29289 17.2929L10.5858 12L5.29289 6.70711C4.90237 6.31658 4.90237 5.68342 5.29289 5.29289Z"
|
||||
fill="#0F1729"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
48
daemon/web/src/lib/components/ReAnalyzeButton.svelte
Normal file
48
daemon/web/src/lib/components/ReAnalyzeButton.svelte
Normal file
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import ApiRequestButton from './ApiRequestButton.svelte';
|
||||
import { AnalysisStatus, AnalysisManager } from '$lib/analysisManager.svelte';
|
||||
import type { ManifestEntry } from '$lib/manifest.svelte';
|
||||
|
||||
let {
|
||||
entry,
|
||||
manager,
|
||||
}: {
|
||||
entry: ManifestEntry;
|
||||
manager: AnalysisManager;
|
||||
} = $props();
|
||||
|
||||
let url = $derived(entry.get_reanalyze_url());
|
||||
let entry_name = $derived(entry.name);
|
||||
let analysis_status = $derived(entry.analysis_status);
|
||||
|
||||
let is_processing = $derived(
|
||||
analysis_status === AnalysisStatus.Queued || analysis_status === AnalysisStatus.Running
|
||||
);
|
||||
|
||||
async function handle_re_analyze() {
|
||||
// Update the entry directly for immediate UI feedback
|
||||
entry.analysis_status = AnalysisStatus.Queued;
|
||||
entry.analysis_report = undefined;
|
||||
manager.set_queued_status(entry_name);
|
||||
}
|
||||
</script>
|
||||
|
||||
<ApiRequestButton
|
||||
{url}
|
||||
label="Re-analyze"
|
||||
loadingLabel="Analyzing..."
|
||||
disabled={is_processing}
|
||||
variant="blue"
|
||||
onclick={handle_re_analyze}
|
||||
ariaLabel="re-analyze"
|
||||
errorMessage="Error re-analyzing recoding"
|
||||
>
|
||||
{#snippet icon()}
|
||||
<svg style="width:20px;height:20px" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="white"
|
||||
d="M12,18A6,6 0 0,1 6,12C6,11 6.25,10.03 6.7,9.2L5.24,7.74C4.46,8.97 4,10.43 4,12A8,8 0 0,0 12,20V23L16,19L12,15M12,4V1L8,5L12,9V6A6,6 0 0,1 18,12C18,13 17.75,13.97 17.3,14.8L18.76,16.26C19.54,15.03 20,13.57 20,12A8,8 0 0,0 12,4Z"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
</ApiRequestButton>
|
||||
60
daemon/web/src/lib/components/RecordingControls.svelte
Normal file
60
daemon/web/src/lib/components/RecordingControls.svelte
Normal file
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import ApiRequestButton from './ApiRequestButton.svelte';
|
||||
let {
|
||||
server_is_recording,
|
||||
}: {
|
||||
server_is_recording: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{#if server_is_recording}
|
||||
<ApiRequestButton
|
||||
url="/api/stop-recording"
|
||||
label="Stop"
|
||||
variant="red"
|
||||
errorMessage="Error stoppping recording"
|
||||
>
|
||||
{#snippet icon()}
|
||||
<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>
|
||||
{/snippet}
|
||||
</ApiRequestButton>
|
||||
{:else}
|
||||
<ApiRequestButton
|
||||
url="/api/start-recording"
|
||||
label="Start"
|
||||
variant="blue"
|
||||
errorMessage="Error starting recording"
|
||||
>
|
||||
{#snippet icon()}
|
||||
<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>
|
||||
{/snippet}
|
||||
</ApiRequestButton>
|
||||
{/if}
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user