mirror of
https://github.com/EFForg/rayhunter.git
synced 2026-05-30 06:49:28 -07:00
Compare commits
629 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 |
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,15 @@
|
||||
[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"]
|
||||
@@ -15,18 +27,36 @@ rustflags = ["-C", "target-feature=+crt-static"]
|
||||
linker = "rust-lld"
|
||||
rustflags = ["-C", "target-feature=+crt-static"]
|
||||
|
||||
[target.x86_64-apple-darwin]
|
||||
[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"]
|
||||
|
||||
# optimizations to reduce the binary size
|
||||
[profile.release]
|
||||
strip = true
|
||||
# keep line numbers in stack traces for non-firmware binaries
|
||||
debug = "limited"
|
||||
lto = "fat"
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
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
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
c5bbaabe15d4ccfee97b9997a13569fbfea13c45
|
||||
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
|
||||
66
.github/ISSUE_TEMPLATE/bug.yaml
vendored
66
.github/ISSUE_TEMPLATE/bug.yaml
vendored
@@ -1,59 +1,25 @@
|
||||
name: Bug Report
|
||||
description: File a bug report.
|
||||
title: "[Bug]: "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this bug report!
|
||||
- type: input
|
||||
attributes:
|
||||
label: Rayhunter Version
|
||||
description: |
|
||||
Which version did you install?
|
||||
placeholder: "v0.2.6"
|
||||
- type: input
|
||||
attributes:
|
||||
label: Capture Date
|
||||
description: |
|
||||
YYYY-MM-DD
|
||||
placeholder: "2025-05-01"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Capture Location
|
||||
description: |
|
||||
(If comfortable disclosing) What region or country were you in?
|
||||
placeholder: Washington State
|
||||
- type: input
|
||||
attributes:
|
||||
label: Device and Model
|
||||
description: |
|
||||
Device you installed Rayhunter on to.
|
||||
placeholder: Orbic RC400L
|
||||
validations:
|
||||
required: true
|
||||
label: Prerequisites
|
||||
options:
|
||||
- label: I have read [CONTRIBUTING.md](https://github.com/EFForg/rayhunter/blob/main/CONTRIBUTING.md)
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What happened?
|
||||
label: Bug Report Details
|
||||
description: |
|
||||
What steps did you take to get to your issue?
|
||||
placeholder: "Tell us what you see!"
|
||||
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
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: Rayhunter's behavior differed from what I expected because.
|
||||
placeholder: "What was expected?"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant log output
|
||||
description: Rayhunter data captures (QMDL and PCAP logs) or error codes
|
||||
render: shell
|
||||
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
8
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,8 +1,10 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Rayhunter Mattermost
|
||||
url: https://opensource.eff.org/signup_user_complete/?id=6iqur37ucfrctfswrs14iscobw&md=link&sbr=su
|
||||
about: If you're having trouble using Rayhunter and aren't sure you've found a bug or request for a new feature, please first try asking for help here. There is a much larger community there of people familiar with the project who will be able to more quickly answer your questions.
|
||||
- name: 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.
|
||||
|
||||
7
.github/ISSUE_TEMPLATE/feature.yaml
vendored
7
.github/ISSUE_TEMPLATE/feature.yaml
vendored
@@ -1,8 +1,13 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or improvement to Rayhunter
|
||||
title: "[Feature Request]: "
|
||||
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:
|
||||
|
||||
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
12
.github/pull_request_template.md
vendored
@@ -1,6 +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.
|
||||
- [ ] 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
|
||||
- [ ] 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.
|
||||
|
||||
154
.github/workflows/build-release.yml
vendored
154
.github/workflows/build-release.yml
vendored
@@ -1,154 +0,0 @@
|
||||
name: Build Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, "release-*"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
FILE_ROOTSHELL: ../../rootshell/rootshell
|
||||
FILE_RAYHUNTER_DAEMON_ORBIC: ../../rayhunter-daemon-orbic/rayhunter-daemon
|
||||
FILE_RAYHUNTER_DAEMON_TPLINK: ../../rayhunter-daemon-tplink/rayhunter-daemon
|
||||
|
||||
jobs:
|
||||
build_rayhunter_check:
|
||||
strategy:
|
||||
matrix:
|
||||
platform:
|
||||
- name: ubuntu-24
|
||||
os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-musl
|
||||
- name: ubuntu-24-aarch64
|
||||
os: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-musl
|
||||
- name: macos-arm
|
||||
os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
- name: macos-intel
|
||||
os: macos-13
|
||||
target: x86_64-apple-darwin
|
||||
- name: windows-x86_64
|
||||
os: windows-latest
|
||||
target: x86_64-pc-windows-gnu
|
||||
runs-on: ${{ matrix.platform.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build rayhunter-check
|
||||
run: cargo build --bin rayhunter-check --release
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rayhunter-check-${{ matrix.platform.name }}
|
||||
path: target/release/rayhunter-check${{ matrix.platform.os == 'windows-latest' && '.exe' || '' }}
|
||||
if-no-files-found: error
|
||||
build_rootshell:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: armv7-unknown-linux-musleabihf
|
||||
- name: Build rootshell (arm32)
|
||||
run: cargo build --bin rootshell --target armv7-unknown-linux-musleabihf --release
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rootshell
|
||||
path: target/armv7-unknown-linux-musleabihf/release/rootshell
|
||||
if-no-files-found: error
|
||||
build_rayhunter:
|
||||
strategy:
|
||||
matrix:
|
||||
device:
|
||||
- name: tplink
|
||||
- name: orbic
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: armv7-unknown-linux-musleabihf
|
||||
- name: Build rayhunter-daemon (arm32)
|
||||
run: |
|
||||
pushd bin/web
|
||||
npm install
|
||||
npm run build
|
||||
popd
|
||||
cargo build --bin rayhunter-daemon --target armv7-unknown-linux-musleabihf --release --no-default-features --features ${{ matrix.device.name }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rayhunter-daemon-${{ matrix.device.name }}
|
||||
path: target/armv7-unknown-linux-musleabihf/release/rayhunter-daemon
|
||||
if-no-files-found: error
|
||||
build_rust_installer:
|
||||
needs:
|
||||
- build_rayhunter
|
||||
strategy:
|
||||
matrix:
|
||||
platform:
|
||||
- name: ubuntu-24
|
||||
os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-musl
|
||||
- name: ubuntu-24-aarch64
|
||||
os: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-musl
|
||||
- name: macos-arm
|
||||
os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
- name: macos-intel
|
||||
os: macos-13
|
||||
target: x86_64-apple-darwin
|
||||
- name: windows-x86_64
|
||||
os: windows-latest
|
||||
target: x86_64-pc-windows-gnu
|
||||
runs-on: ${{ matrix.platform.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.platform.target }}
|
||||
- run: cargo build --bin installer --release --target ${{ matrix.platform.target }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: installer-${{ matrix.platform.name }}
|
||||
path: target/${{ matrix.platform.target }}/release/installer${{ matrix.platform.os == 'windows-latest' && '.exe' || '' }}
|
||||
if-no-files-found: error
|
||||
|
||||
build_release_zip:
|
||||
needs:
|
||||
- build_rayhunter_check
|
||||
- build_rootshell
|
||||
- build_rayhunter
|
||||
- build_rust_installer
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
- name: Fix executable permissions on binaries
|
||||
run: chmod +x installer-*/installer rayhunter-check-*/rayhunter-check rayhunter-daemon-*/rayhunter-daemon
|
||||
- name: Get Rayhunter version
|
||||
id: get_version
|
||||
run: echo "VERSION=$(grep '^version' bin/Cargo.toml | head -n 1 | cut -d'"' -f2)" >> $GITHUB_ENV
|
||||
- name: Setup versioned release directory
|
||||
run: |
|
||||
VERSIONED_DIR="rayhunter-v${{ env.VERSION }}"
|
||||
mkdir "$VERSIONED_DIR"
|
||||
mv rayhunter-daemon-* rootshell/rootshell installer-* "$VERSIONED_DIR"/
|
||||
- name: Archive release directory as zip
|
||||
run: |
|
||||
VERSIONED_DIR="rayhunter-v${{ env.VERSION }}"
|
||||
zip -r "$VERSIONED_DIR.zip" "$VERSIONED_DIR"
|
||||
- name: Compute SHA256 of zip
|
||||
run: |
|
||||
VERSIONED_DIR="rayhunter-v${{ env.VERSION }}"
|
||||
sha256sum "$VERSIONED_DIR.zip" > "$VERSIONED_DIR.zip.sha256"
|
||||
# TODO: have this create a release directly
|
||||
- name: Upload zip release and sha256
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rayhunter-v${{ env.VERSION }}
|
||||
path: |
|
||||
rayhunter-v${{ env.VERSION }}.zip
|
||||
rayhunter-v${{ env.VERSION }}.zip.sha256
|
||||
if-no-files-found: error
|
||||
54
.github/workflows/check-and-test.yml
vendored
54
.github/workflows/check-and-test.yml
vendored
@@ -1,54 +0,0 @@
|
||||
name: Check and Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
NO_FIRMWARE_BIN: true
|
||||
|
||||
jobs:
|
||||
check_and_test:
|
||||
strategy:
|
||||
matrix:
|
||||
device:
|
||||
- name: tplink
|
||||
- name: orbic
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Check
|
||||
run: |
|
||||
pushd bin/web
|
||||
npm install
|
||||
npm run build
|
||||
popd
|
||||
cargo check --verbose --no-default-features --features=${{ matrix.device.name }}
|
||||
- name: Run tests
|
||||
run: |
|
||||
pushd bin/web
|
||||
npm install
|
||||
npm run build
|
||||
popd
|
||||
cargo test --verbose --no-default-features --features=${{ matrix.device.name }}
|
||||
- name: Run clippy
|
||||
run: cargo clippy --verbose --no-default-features --features=${{ matrix.device.name }}
|
||||
|
||||
windows_installer_check_and_test:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: cargo check
|
||||
shell: bash
|
||||
run: |
|
||||
cd installer
|
||||
cargo check --verbose
|
||||
- name: cargo test
|
||||
shell: bash
|
||||
run: |
|
||||
cd installer
|
||||
cargo test --verbose --no-default-features --features=${{ matrix.device.name }}
|
||||
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
|
||||
47
.github/workflows/mdbook.yaml
vendored
47
.github/workflows/mdbook.yaml
vendored
@@ -1,47 +0,0 @@
|
||||
# On Repository Settings > Pages > Build and deployment
|
||||
# Set "Source" to GitHub Actions.
|
||||
name: Documentation
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
jobs:
|
||||
mdbook_test:
|
||||
name: Test mdBook Documentation builds
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install mdBook
|
||||
run: |
|
||||
cargo install mdbook --no-default-features --features search --vers "^0.4" --locked
|
||||
- name: Test mdBook
|
||||
run: mdbook test
|
||||
|
||||
mdbook_publish:
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
needs: mdbook_test
|
||||
permissions:
|
||||
pages: write
|
||||
contents: write
|
||||
id-token: write
|
||||
name: Publish mdBook to Github Pages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install mdBook
|
||||
run: |
|
||||
cargo install mdbook --no-default-features --features search --vers "^0.4" --locked
|
||||
|
||||
- name: Build mdBook
|
||||
run: mdbook build
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v4
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: book
|
||||
- name: Deploy to Github Pages
|
||||
uses: actions/deploy-pages@v4
|
||||
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*
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
/target
|
||||
/book
|
||||
.DS_Store
|
||||
|
||||
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.
|
||||
4478
Cargo.lock
generated
4478
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
15
Cargo.toml
15
Cargo.toml
@@ -2,7 +2,20 @@
|
||||
|
||||
members = [
|
||||
"lib",
|
||||
"bin",
|
||||
"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",
|
||||
|
||||
18
README.md
18
README.md
@@ -1,7 +1,19 @@
|
||||
# Rayhunter
|
||||

|
||||
|
||||

|
||||
|
||||
# Rayhunter
|
||||
Rayhunter is a project for detecting IMSI catchers, also known as cell-site simulators or stingrays. It was first designed to run on a cheap mobile hotspot called the Orbic RC400L, but thanks to community efforts, 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.
|
||||
|
||||
Rayhunter is an IMSI Catcher Catcher for the Orbic mobile hotspot. To learn more, check out the [Rayhunter Book](https://efforg.github.io/rayhunter/).
|
||||
→ To learn more about the aim of the project, and about IMSI catchers in general, please check out our [introductory blog post](https://www.eff.org/deeplinks/2025/03/meet-rayhunter-new-open-source-tool-eff-detect-cellular-spying).
|
||||
|
||||
→ For discussion, help, or to join the mattermost channel and get involved with the project and community check out the [many ways listed here](https://efforg.github.io/rayhunter/support-feedback-community.html)!
|
||||
|
||||
→ To learn more about the project in general check out the [Rayhunter Book](https://efforg.github.io/rayhunter/).
|
||||
|
||||
**LEGAL DISCLAIMER:** Use this program at your own risk. We believe running this program does not currently violate any laws or regulations in the United States. However, we are not responsible for civil or criminal liability resulting from the use of this software. If you are located outside of the US please consult with an attorney in your country to help you assess the legal risks of running this program.
|
||||
|
||||
*Good Hunting!*
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
[package]
|
||||
name = "rayhunter-daemon"
|
||||
version = "0.3.0"
|
||||
edition = "2021"
|
||||
|
||||
[features]
|
||||
# These feature flags are mutually exclusive, and exactly one must be enabled.
|
||||
orbic = ["rayhunter/orbic"]
|
||||
tplink = ["rayhunter/tplink"]
|
||||
|
||||
default = ["orbic"]
|
||||
|
||||
[[bin]]
|
||||
name = "rayhunter-daemon"
|
||||
path = "src/daemon.rs"
|
||||
|
||||
[[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.44.2", features = ["full"] }
|
||||
axum = "0.8"
|
||||
futures-core = "0.3.30"
|
||||
thiserror = "1.0.52"
|
||||
libc = "0.2.150"
|
||||
log = "0.4.20"
|
||||
env_logger = "0.10.1"
|
||||
tokio-util = { version = "0.7.10", features = ["rt", "io"] }
|
||||
futures-macro = "0.3.30"
|
||||
include_dir = "0.7.3"
|
||||
mime_guess = "2.0.4"
|
||||
chrono = { version = "0.4.31", features = ["serde"] }
|
||||
tokio-stream = "0.1.14"
|
||||
futures = "0.3.30"
|
||||
clap = { version = "4.5.2", features = ["derive"] }
|
||||
serde_json = "1.0.114"
|
||||
image = { version = "0.25.1", default-features = false, features = ["png", "gif"] }
|
||||
tempfile = "3.10.1"
|
||||
simple_logger = "5.0.0"
|
||||
176
bin/src/check.rs
176
bin/src/check.rs
@@ -1,176 +0,0 @@
|
||||
use clap::Parser;
|
||||
use futures::TryStreamExt;
|
||||
use log::{info, warn};
|
||||
use rayhunter::{
|
||||
analysis::analyzer::{EventType, Harness},
|
||||
diag::DataType,
|
||||
gsmtap_parser,
|
||||
pcap::GsmtapPcapWriter,
|
||||
qmdl::QmdlReader,
|
||||
};
|
||||
use std::{collections::HashMap, future, path::PathBuf, pin::pin};
|
||||
use tokio::fs::{metadata, read_dir, File};
|
||||
|
||||
mod dummy_analyzer;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version, about)]
|
||||
struct Args {
|
||||
#[arg(short = 'p', long)]
|
||||
qmdl_path: PathBuf,
|
||||
|
||||
#[arg(short = 'c', long)]
|
||||
pcapify: bool,
|
||||
|
||||
#[arg(long)]
|
||||
show_skipped: bool,
|
||||
|
||||
#[arg(long)]
|
||||
enable_dummy_analyzer: bool,
|
||||
|
||||
#[arg(short, long)]
|
||||
verbose: bool,
|
||||
}
|
||||
|
||||
async fn analyze_file(enable_dummy_analyzer: bool, qmdl_path: &str, show_skipped: bool) {
|
||||
let mut harness = Harness::new_with_all_analyzers();
|
||||
if enable_dummy_analyzer {
|
||||
harness.add_analyzer(Box::new(dummy_analyzer::TestAnalyzer { count: 0 }));
|
||||
}
|
||||
let qmdl_file = &mut File::open(&qmdl_path).await.expect("failed to open file");
|
||||
let file_size = qmdl_file
|
||||
.metadata()
|
||||
.await
|
||||
.expect("failed to get QMDL file metadata")
|
||||
.len();
|
||||
let mut qmdl_reader = QmdlReader::new(qmdl_file, Some(file_size as usize));
|
||||
let mut qmdl_stream = pin!(qmdl_reader
|
||||
.as_stream()
|
||||
.try_filter(|container| future::ready(container.data_type == DataType::UserSpace)));
|
||||
let mut skipped_reasons: HashMap<String, i32> = HashMap::new();
|
||||
let mut total_messages = 0;
|
||||
let mut warnings = 0;
|
||||
let mut skipped = 0;
|
||||
while let Some(container) = qmdl_stream
|
||||
.try_next()
|
||||
.await
|
||||
.expect("failed getting QMDL container")
|
||||
{
|
||||
let row = harness.analyze_qmdl_messages(container);
|
||||
total_messages += 1;
|
||||
for reason in row.skipped_message_reasons {
|
||||
*skipped_reasons.entry(reason).or_insert(0) += 1;
|
||||
skipped += 1;
|
||||
}
|
||||
for analysis in row.analysis {
|
||||
for maybe_event in analysis.events {
|
||||
let Some(event) = maybe_event else { continue };
|
||||
match event.event_type {
|
||||
EventType::Informational => {
|
||||
info!(
|
||||
"{}: INFO - {} {}",
|
||||
qmdl_path, analysis.timestamp, event.message,
|
||||
);
|
||||
}
|
||||
EventType::QualitativeWarning { severity } => {
|
||||
warn!(
|
||||
"{}: WARNING (Severity: {:?}) - {} {}",
|
||||
qmdl_path, severity, analysis.timestamp, event.message,
|
||||
);
|
||||
warnings += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if show_skipped && skipped > 0 {
|
||||
info!("{}: messages skipped:", qmdl_path);
|
||||
for (reason, count) in skipped_reasons.iter() {
|
||||
info!(" - {}: \"{}\"", count, reason);
|
||||
}
|
||||
}
|
||||
info!(
|
||||
"{}: {} messages analyzed, {} warnings, {} messages skipped",
|
||||
qmdl_path, total_messages, warnings, skipped
|
||||
);
|
||||
}
|
||||
|
||||
async fn pcapify(qmdl_path: &PathBuf) {
|
||||
let qmdl_file = &mut File::open(&qmdl_path)
|
||||
.await
|
||||
.expect("failed to open qmdl file");
|
||||
let qmdl_file_size = qmdl_file.metadata().await.unwrap().len();
|
||||
let mut qmdl_reader = QmdlReader::new(qmdl_file, Some(qmdl_file_size as usize));
|
||||
let mut pcap_path = qmdl_path.clone();
|
||||
pcap_path.set_extension("pcap");
|
||||
let pcap_file = &mut File::create(&pcap_path)
|
||||
.await
|
||||
.expect("failed to open pcap file");
|
||||
let mut pcap_writer = GsmtapPcapWriter::new(pcap_file).await.unwrap();
|
||||
pcap_writer.write_iface_header().await.unwrap();
|
||||
while let Some(container) = qmdl_reader
|
||||
.get_next_messages_container()
|
||||
.await
|
||||
.expect("failed to get container")
|
||||
{
|
||||
for msg in container.into_messages().into_iter().flatten() {
|
||||
if let Ok(Some((timestamp, parsed))) = gsmtap_parser::parse(msg) {
|
||||
pcap_writer
|
||||
.write_gsmtap_message(parsed, timestamp)
|
||||
.await
|
||||
.expect("failed to write");
|
||||
}
|
||||
}
|
||||
}
|
||||
info!("wrote pcap to {:?}", &pcap_path);
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let args = Args::parse();
|
||||
let level = if args.verbose {
|
||||
log::LevelFilter::Trace
|
||||
} else {
|
||||
log::LevelFilter::Warn
|
||||
};
|
||||
simple_logger::SimpleLogger::new()
|
||||
.with_colors(true)
|
||||
.without_timestamps()
|
||||
.with_level(level)
|
||||
.init()
|
||||
.unwrap();
|
||||
info!("Analyzers:");
|
||||
|
||||
let mut harness = Harness::new_with_all_analyzers();
|
||||
if args.enable_dummy_analyzer {
|
||||
harness.add_analyzer(Box::new(dummy_analyzer::TestAnalyzer { count: 0 }));
|
||||
}
|
||||
for analyzer in harness.get_metadata().analyzers {
|
||||
info!(" - {}: {}", analyzer.name, analyzer.description);
|
||||
}
|
||||
|
||||
let metadata = metadata(&args.qmdl_path)
|
||||
.await
|
||||
.expect("failed to get metadata");
|
||||
if metadata.is_dir() {
|
||||
let mut dir = read_dir(&args.qmdl_path).await.expect("failed to read dir");
|
||||
while let Some(entry) = dir.next_entry().await.expect("failed to get entry") {
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_str().unwrap();
|
||||
if name_str.ends_with(".qmdl") {
|
||||
let path = entry.path();
|
||||
let path_str = path.to_str().unwrap();
|
||||
analyze_file(args.enable_dummy_analyzer, path_str, args.show_skipped).await;
|
||||
if args.pcapify {
|
||||
pcapify(&path).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let path = args.qmdl_path.to_str().unwrap();
|
||||
analyze_file(args.enable_dummy_analyzer, path, args.show_skipped).await;
|
||||
if args.pcapify {
|
||||
pcapify(&args.qmdl_path).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
use crate::error::RayhunterError;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
pub qmdl_store_path: String,
|
||||
pub port: u16,
|
||||
pub debug_mode: bool,
|
||||
pub ui_level: u8,
|
||||
pub enable_dummy_analyzer: bool,
|
||||
pub colorblind_mode: bool,
|
||||
}
|
||||
|
||||
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,
|
||||
colorblind_mode: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_config<P>(path: P) -> Result<Config, RayhunterError>
|
||||
where
|
||||
P: AsRef<std::path::Path>,
|
||||
{
|
||||
if let Ok(config_file) = std::fs::read_to_string(&path) {
|
||||
Ok(toml::from_str(&config_file).map_err(RayhunterError::ConfigFileParsingError)?)
|
||||
} else {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
325
bin/src/diag.rs
325
bin/src/diag.rs
@@ -1,325 +0,0 @@
|
||||
use std::pin::pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::header::CONTENT_TYPE;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use futures::{StreamExt, TryStreamExt};
|
||||
use log::{debug, error, info};
|
||||
use rayhunter::diag::DataType;
|
||||
use rayhunter::diag_device::DiagDevice;
|
||||
use rayhunter::qmdl::QmdlWriter;
|
||||
use tokio::fs::File;
|
||||
use tokio::sync::mpsc::{Receiver, Sender};
|
||||
use tokio::sync::RwLock;
|
||||
use tokio_util::io::ReaderStream;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
use crate::analysis::{AnalysisCtrlMessage, AnalysisWriter};
|
||||
use crate::display;
|
||||
use crate::qmdl_store::{RecordingStore, RecordingStoreError};
|
||||
use crate::server::ServerState;
|
||||
|
||||
pub enum DiagDeviceCtrlMessage {
|
||||
StopRecording,
|
||||
StartRecording((QmdlWriter<File>, File)),
|
||||
Exit,
|
||||
}
|
||||
|
||||
pub fn run_diag_read_thread(
|
||||
task_tracker: &TaskTracker,
|
||||
mut dev: DiagDevice,
|
||||
mut qmdl_file_rx: Receiver<DiagDeviceCtrlMessage>,
|
||||
ui_update_sender: Sender<display::DisplayState>,
|
||||
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
|
||||
enable_dummy_analyzer: bool,
|
||||
) {
|
||||
task_tracker.spawn(async move {
|
||||
let (initial_qmdl_file, initial_analysis_file) = qmdl_store_lock.write().await.new_entry().await.expect("failed creating QMDL file entry");
|
||||
let mut maybe_qmdl_writer: Option<QmdlWriter<File>> = Some(QmdlWriter::new(initial_qmdl_file));
|
||||
let mut diag_stream = pin!(dev.as_stream().into_stream());
|
||||
let mut maybe_analysis_writer = Some(AnalysisWriter::new(initial_analysis_file, enable_dummy_analyzer).await
|
||||
.expect("failed to create analysis writer"));
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg = qmdl_file_rx.recv() => {
|
||||
match msg {
|
||||
Some(DiagDeviceCtrlMessage::StartRecording((new_writer, new_analysis_file))) => {
|
||||
maybe_qmdl_writer = Some(new_writer);
|
||||
if let Some(analysis_writer) = maybe_analysis_writer {
|
||||
analysis_writer.close().await.expect("failed to close analysis writer");
|
||||
}
|
||||
maybe_analysis_writer = Some(AnalysisWriter::new(new_analysis_file, enable_dummy_analyzer).await
|
||||
.expect("failed to write to analysis file"));
|
||||
},
|
||||
Some(DiagDeviceCtrlMessage::StopRecording) => {
|
||||
maybe_qmdl_writer = None;
|
||||
if let Some(analysis_writer) = maybe_analysis_writer {
|
||||
analysis_writer.close().await.expect("failed to close analysis writer");
|
||||
}
|
||||
maybe_analysis_writer = None;
|
||||
},
|
||||
// None means all the Senders have been dropped, so it's
|
||||
// time to go
|
||||
Some(DiagDeviceCtrlMessage::Exit) | None => {
|
||||
info!("Diag reader thread exiting...");
|
||||
if let Some(analysis_writer) = maybe_analysis_writer {
|
||||
analysis_writer.close().await.expect("failed to close analysis writer");
|
||||
}
|
||||
return Ok(())
|
||||
},
|
||||
}
|
||||
}
|
||||
maybe_container = diag_stream.next() => {
|
||||
match maybe_container.unwrap() {
|
||||
Ok(container) => {
|
||||
if container.data_type != DataType::UserSpace {
|
||||
debug!("skipping non-userspace diag messages...");
|
||||
continue;
|
||||
}
|
||||
// keep track of how many bytes were written to the QMDL file so we can read
|
||||
// a valid block of data from it in the HTTP server
|
||||
if let Some(qmdl_writer) = maybe_qmdl_writer.as_mut() {
|
||||
qmdl_writer.write_container(&container).await.expect("failed to write to QMDL writer");
|
||||
debug!("total QMDL bytes written: {}, updating manifest...", qmdl_writer.total_written);
|
||||
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||
let index = qmdl_store.current_entry.expect("DiagDevice had qmdl_writer, but QmdlStore didn't have current entry???");
|
||||
qmdl_store.update_entry_qmdl_size(index, qmdl_writer.total_written).await
|
||||
.expect("failed to update qmdl file size");
|
||||
debug!("done!");
|
||||
} else {
|
||||
debug!("no qmdl_writer set, continuing...");
|
||||
}
|
||||
|
||||
if let Some(analysis_writer) = maybe_analysis_writer.as_mut() {
|
||||
let analysis_output = analysis_writer.analyze(container).await
|
||||
.expect("failed to analyze container");
|
||||
let (analysis_file_len, heuristic_warning) = analysis_output;
|
||||
if heuristic_warning {
|
||||
info!("a heuristic triggered on this run!");
|
||||
ui_update_sender.send(display::DisplayState::WarningDetected).await
|
||||
.expect("couldn't send ui update message: {}");
|
||||
}
|
||||
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||
let index = qmdl_store.current_entry.expect("DiagDevice had qmdl_writer, but QmdlStore didn't have current entry???");
|
||||
qmdl_store.update_entry_analysis_size(index, analysis_file_len).await
|
||||
.expect("failed to update analysis file size");
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
error!("error reading diag device: {}", err);
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn start_recording(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||
if state.debug_mode {
|
||||
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
|
||||
}
|
||||
let mut qmdl_store = state.qmdl_store_lock.write().await;
|
||||
let (qmdl_file, analysis_file) = qmdl_store.new_entry().await.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("couldn't create new qmdl entry: {}", e),
|
||||
)
|
||||
})?;
|
||||
let qmdl_writer = QmdlWriter::new(qmdl_file);
|
||||
state
|
||||
.diag_device_ctrl_sender
|
||||
.send(DiagDeviceCtrlMessage::StartRecording((
|
||||
qmdl_writer,
|
||||
analysis_file,
|
||||
)))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("couldn't send stop recording message: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let display_state = display::DisplayState::Recording;
|
||||
state
|
||||
.ui_update_sender
|
||||
.send(display_state)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("couldn't send ui update message: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok((StatusCode::ACCEPTED, "ok".to_string()))
|
||||
}
|
||||
|
||||
pub async fn stop_recording(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||
if state.debug_mode {
|
||||
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
|
||||
}
|
||||
let mut qmdl_store = state.qmdl_store_lock.write().await;
|
||||
match qmdl_store.get_current_entry() {
|
||||
Some((_, entry)) => {
|
||||
state
|
||||
.analysis_sender
|
||||
.send(AnalysisCtrlMessage::RecordingFinished(
|
||||
entry.name.to_string(),
|
||||
))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("couldn't send AnalysisCtrlMessage: {}", e),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
None => todo!(),
|
||||
}
|
||||
qmdl_store.close_current_entry().await.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("couldn't close current qmdl entry: {}", e),
|
||||
)
|
||||
})?;
|
||||
state
|
||||
.diag_device_ctrl_sender
|
||||
.send(DiagDeviceCtrlMessage::StopRecording)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("couldn't send stop recording message: {}", e),
|
||||
)
|
||||
})?;
|
||||
state
|
||||
.ui_update_sender
|
||||
.send(display::DisplayState::Paused)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("couldn't send ui update message: {}", e),
|
||||
)
|
||||
})?;
|
||||
Ok((StatusCode::ACCEPTED, "ok".to_string()))
|
||||
}
|
||||
|
||||
pub async fn delete_recording(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
Path(qmdl_name): Path<String>,
|
||||
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||
if state.debug_mode {
|
||||
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
|
||||
}
|
||||
let mut qmdl_store = state.qmdl_store_lock.write().await;
|
||||
match qmdl_store.delete_entry(&qmdl_name).await {
|
||||
Err(RecordingStoreError::NoSuchEntryError) => {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("no recording with name {qmdl_name}"),
|
||||
))
|
||||
}
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("couldn't delete recording: {e}"),
|
||||
))
|
||||
}
|
||||
Ok(_) => {}
|
||||
}
|
||||
state
|
||||
.diag_device_ctrl_sender
|
||||
.send(DiagDeviceCtrlMessage::StopRecording)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("couldn't send stop recording message: {}", e),
|
||||
)
|
||||
})?;
|
||||
state
|
||||
.ui_update_sender
|
||||
.send(display::DisplayState::Paused)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("couldn't send ui update message: {}", e),
|
||||
)
|
||||
})?;
|
||||
Ok((StatusCode::ACCEPTED, "ok".to_string()))
|
||||
}
|
||||
|
||||
pub async fn delete_all_recordings(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||
if state.debug_mode {
|
||||
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
|
||||
}
|
||||
state
|
||||
.diag_device_ctrl_sender
|
||||
.send(DiagDeviceCtrlMessage::StopRecording)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("couldn't send stop recording message: {}", e),
|
||||
)
|
||||
})?;
|
||||
let mut qmdl_store = state.qmdl_store_lock.write().await;
|
||||
qmdl_store.delete_all_entries().await.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("couldn't delete all recordings: {}", e),
|
||||
)
|
||||
})?;
|
||||
state
|
||||
.ui_update_sender
|
||||
.send(display::DisplayState::Paused)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("couldn't send ui update message: {}", e),
|
||||
)
|
||||
})?;
|
||||
Ok((StatusCode::ACCEPTED, "ok".to_string()))
|
||||
}
|
||||
|
||||
pub async fn get_analysis_report(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
Path(qmdl_name): Path<String>,
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
let qmdl_store = state.qmdl_store_lock.read().await;
|
||||
let (entry_index, _) = if qmdl_name == "live" {
|
||||
qmdl_store.get_current_entry().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"No QMDL data's being recorded to analyze, try starting a new recording!".to_string(),
|
||||
))?
|
||||
} else {
|
||||
qmdl_store.entry_for_name(&qmdl_name).ok_or((
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("Couldn't find QMDL entry with name \"{}\"", qmdl_name),
|
||||
))?
|
||||
};
|
||||
let analysis_file = qmdl_store
|
||||
.open_entry_analysis(entry_index)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)))?;
|
||||
let analysis_stream = ReaderStream::new(analysis_file);
|
||||
|
||||
let headers = [(CONTENT_TYPE, "application/x-ndjson")];
|
||||
let body = Body::from_stream(analysis_stream);
|
||||
Ok((headers, body).into_response())
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
use image::{codecs::gif::GifDecoder, imageops::FilterType, AnimationDecoder, DynamicImage};
|
||||
use std::io::Cursor;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config;
|
||||
use crate::display::DisplayState;
|
||||
|
||||
use log::{error, info};
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::sync::oneshot::error::TryRecvError;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
use std::thread::sleep;
|
||||
|
||||
use include_dir::{include_dir, Dir};
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct Dimensions {
|
||||
pub height: u32,
|
||||
pub width: u32,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Copy, Clone)]
|
||||
pub enum Color {
|
||||
Red,
|
||||
Green,
|
||||
Blue,
|
||||
White,
|
||||
Black,
|
||||
Cyan,
|
||||
Yellow,
|
||||
Pink,
|
||||
}
|
||||
|
||||
impl Color {
|
||||
fn rgb(self) -> (u8, u8, u8) {
|
||||
match self {
|
||||
Color::Red => (0xff, 0, 0),
|
||||
Color::Green => (0, 0xff, 0),
|
||||
Color::Blue => (0, 0, 0xff),
|
||||
Color::White => (0xff, 0xff, 0xff),
|
||||
Color::Black => (0, 0, 0),
|
||||
Color::Cyan => (0, 0xff, 0xff),
|
||||
Color::Yellow => (0xff, 0xff, 0),
|
||||
Color::Pink => (0xfe, 0x24, 0xff),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Color {
|
||||
fn from_state(state: DisplayState, colorblind_mode: bool) -> Self {
|
||||
match state {
|
||||
DisplayState::Paused => Color::White,
|
||||
DisplayState::Recording => {
|
||||
if colorblind_mode {
|
||||
Color::Blue
|
||||
} else {
|
||||
Color::Green
|
||||
}
|
||||
}
|
||||
DisplayState::WarningDetected => Color::Red,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait GenericFramebuffer: Send + 'static {
|
||||
fn dimensions(&self) -> Dimensions;
|
||||
|
||||
fn write_buffer(
|
||||
&mut self,
|
||||
buffer: &[(u8, u8, u8)], // rgb, row-wise, left-to-right, top-to-bottom
|
||||
);
|
||||
|
||||
fn write_dynamic_image(&mut self, img: DynamicImage) {
|
||||
let dimensions = self.dimensions();
|
||||
let mut width = img.width();
|
||||
let mut height = img.height();
|
||||
let resized_img: DynamicImage;
|
||||
if height > dimensions.height || width > dimensions.width {
|
||||
resized_img = img.resize(dimensions.width, dimensions.height, FilterType::CatmullRom);
|
||||
width = dimensions.width.min(resized_img.width());
|
||||
height = dimensions.height.min(resized_img.height());
|
||||
} else {
|
||||
resized_img = img;
|
||||
}
|
||||
let img_rgba8 = resized_img.as_rgba8().unwrap();
|
||||
let mut buf = Vec::new();
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let px = img_rgba8.get_pixel(x, y);
|
||||
buf.push((px[0], px[1], px[2]));
|
||||
}
|
||||
}
|
||||
|
||||
self.write_buffer(&buf);
|
||||
}
|
||||
|
||||
fn draw_gif(&mut self, img_buffer: &[u8]) {
|
||||
// this is dumb and i'm sure there's a better way to loop this
|
||||
let cursor = Cursor::new(img_buffer);
|
||||
let decoder = GifDecoder::new(cursor).unwrap();
|
||||
for maybe_frame in decoder.into_frames() {
|
||||
let frame = maybe_frame.unwrap();
|
||||
let (numerator, _) = frame.delay().numer_denom_ms();
|
||||
let img = DynamicImage::from(frame.into_buffer());
|
||||
self.write_dynamic_image(img);
|
||||
std::thread::sleep(Duration::from_millis(numerator as u64));
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_img(&mut self, img_buffer: &[u8]) {
|
||||
let img = image::load_from_memory(img_buffer).unwrap();
|
||||
self.write_dynamic_image(img);
|
||||
}
|
||||
|
||||
fn draw_line(&mut self, color: Color, height: u32) {
|
||||
let width = self.dimensions().width;
|
||||
let px_num = height * width;
|
||||
let mut buffer = Vec::new();
|
||||
for _ in 0..px_num {
|
||||
buffer.push(color.rgb());
|
||||
}
|
||||
|
||||
self.write_buffer(&buffer);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_ui(
|
||||
task_tracker: &TaskTracker,
|
||||
config: &config::Config,
|
||||
mut fb: impl GenericFramebuffer,
|
||||
mut ui_shutdown_rx: oneshot::Receiver<()>,
|
||||
mut ui_update_rx: Receiver<DisplayState>,
|
||||
) {
|
||||
static IMAGE_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/images/");
|
||||
let display_level = config.ui_level;
|
||||
if display_level == 0 {
|
||||
info!("Invisible mode, not spawning UI.");
|
||||
}
|
||||
|
||||
let colorblind_mode = config.colorblind_mode;
|
||||
let mut display_color = Color::from_state(DisplayState::Recording, colorblind_mode);
|
||||
|
||||
task_tracker.spawn_blocking(move || {
|
||||
// this feels wrong, is there a more rusty way to do this?
|
||||
let mut img: Option<&[u8]> = None;
|
||||
if display_level == 2 {
|
||||
img = Some(
|
||||
IMAGE_DIR
|
||||
.get_file("orca.gif")
|
||||
.expect("failed to read orca.gif")
|
||||
.contents(),
|
||||
);
|
||||
} else if display_level == 3 {
|
||||
img = Some(
|
||||
IMAGE_DIR
|
||||
.get_file("eff.png")
|
||||
.expect("failed to read eff.png")
|
||||
.contents(),
|
||||
);
|
||||
}
|
||||
loop {
|
||||
match ui_shutdown_rx.try_recv() {
|
||||
Ok(_) => {
|
||||
info!("received UI shutdown");
|
||||
break;
|
||||
}
|
||||
Err(TryRecvError::Empty) => {}
|
||||
Err(e) => panic!("error receiving shutdown message: {e}"),
|
||||
}
|
||||
match ui_update_rx.try_recv() {
|
||||
Ok(state) => {
|
||||
display_color = Color::from_state(state, colorblind_mode);
|
||||
}
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
|
||||
Err(e) => error!("error receiving framebuffer update message: {e}"),
|
||||
}
|
||||
|
||||
match display_level {
|
||||
2 => {
|
||||
fb.draw_gif(img.unwrap());
|
||||
}
|
||||
3 => fb.draw_img(img.unwrap()),
|
||||
128 => {
|
||||
fb.draw_line(Color::Cyan, 128);
|
||||
fb.draw_line(Color::Pink, 102);
|
||||
fb.draw_line(Color::White, 76);
|
||||
fb.draw_line(Color::Pink, 50);
|
||||
fb.draw_line(Color::Cyan, 25);
|
||||
}
|
||||
_ => {
|
||||
// this branch id for ui_level 1, which is also the default if an
|
||||
// unknown value is used
|
||||
fb.draw_line(display_color, 2);
|
||||
}
|
||||
};
|
||||
sleep(Duration::from_millis(1000));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
mod generic_framebuffer;
|
||||
|
||||
#[cfg(feature = "tplink")]
|
||||
mod tplink;
|
||||
#[cfg(feature = "tplink")]
|
||||
mod tplink_framebuffer;
|
||||
#[cfg(feature = "tplink")]
|
||||
mod tplink_onebit;
|
||||
|
||||
#[cfg(feature = "tplink")]
|
||||
pub use tplink::update_ui;
|
||||
|
||||
#[cfg(feature = "orbic")]
|
||||
mod orbic;
|
||||
#[cfg(feature = "orbic")]
|
||||
pub use orbic::update_ui;
|
||||
|
||||
pub enum DisplayState {
|
||||
Recording,
|
||||
Paused,
|
||||
WarningDetected,
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "orbic", feature = "tplink"))]
|
||||
compile_error!("cannot compile for many devices at once");
|
||||
|
||||
#[cfg(not(any(feature = "orbic", feature = "tplink")))]
|
||||
compile_error!("cannot compile for no device at all");
|
||||
@@ -1,51 +0,0 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use rayhunter::telcom_parser::lte_rrc::{PCCH_MessageType, PCCH_MessageType_c1, PagingUE_Identity};
|
||||
|
||||
use rayhunter::analysis::analyzer::{Analyzer, Event, EventType, Severity};
|
||||
use rayhunter::analysis::information_element::{InformationElement, LteInformationElement};
|
||||
|
||||
pub struct TestAnalyzer {
|
||||
pub count: i32,
|
||||
}
|
||||
|
||||
impl Analyzer for TestAnalyzer {
|
||||
fn get_name(&self) -> Cow<str> {
|
||||
Cow::from("Example Analyzer")
|
||||
}
|
||||
|
||||
fn get_description(&self) -> Cow<str> {
|
||||
Cow::from("Always returns true, if you are seeing this you are either a developer or you are about to have problems.")
|
||||
}
|
||||
|
||||
fn analyze_information_element(&mut self, ie: &InformationElement) -> Option<Event> {
|
||||
self.count += 1;
|
||||
if self.count % 100 == 0 {
|
||||
return Some(Event {
|
||||
event_type: EventType::Informational,
|
||||
message: "multiple of 100 events processed".to_string(),
|
||||
});
|
||||
}
|
||||
let pcch_msg = match ie {
|
||||
InformationElement::LTE(lte_ie) => match &**lte_ie {
|
||||
LteInformationElement::PCCH(pcch_msg) => pcch_msg,
|
||||
_ => return None,
|
||||
},
|
||||
_ => return None,
|
||||
};
|
||||
let PCCH_MessageType::C1(PCCH_MessageType_c1::Paging(paging)) = &pcch_msg.message else {
|
||||
return None;
|
||||
};
|
||||
for record in &paging.paging_record_list.as_ref()?.0 {
|
||||
if let PagingUE_Identity::S_TMSI(_) = record.ue_identity {
|
||||
return Some(Event {
|
||||
event_type: EventType::QualitativeWarning {
|
||||
severity: Severity::Low,
|
||||
},
|
||||
message: "TMSI was provided to cell".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
use crate::ServerState;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::header::CONTENT_TYPE;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use futures::TryStreamExt;
|
||||
use log::error;
|
||||
use rayhunter::diag::DataType;
|
||||
use rayhunter::gsmtap_parser;
|
||||
use rayhunter::pcap::GsmtapPcapWriter;
|
||||
use rayhunter::qmdl::QmdlReader;
|
||||
use std::sync::Arc;
|
||||
use std::{future, pin::pin};
|
||||
use tokio::io::duplex;
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
// Streams a pcap file chunk-by-chunk to the client by reading the QMDL data
|
||||
// written so far. This is done by spawning a thread which streams chunks of
|
||||
// pcap data to a channel that's piped to the client.
|
||||
pub async fn get_pcap(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
Path(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,78 +0,0 @@
|
||||
use axum::body::Body;
|
||||
use axum::extract::Path;
|
||||
use axum::extract::State;
|
||||
use axum::http::header::{self, CONTENT_LENGTH, CONTENT_TYPE};
|
||||
use axum::http::{HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use include_dir::{include_dir, Dir};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::sync::mpsc::Sender;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
use crate::analysis::{AnalysisCtrlMessage, AnalysisStatus};
|
||||
use crate::qmdl_store::RecordingStore;
|
||||
use crate::{display, DiagDeviceCtrlMessage};
|
||||
|
||||
pub struct ServerState {
|
||||
pub qmdl_store_lock: Arc<RwLock<RecordingStore>>,
|
||||
pub diag_device_ctrl_sender: Sender<DiagDeviceCtrlMessage>,
|
||||
pub ui_update_sender: Sender<display::DisplayState>,
|
||||
pub analysis_status_lock: Arc<RwLock<AnalysisStatus>>,
|
||||
pub analysis_sender: Sender<AnalysisCtrlMessage>,
|
||||
pub debug_mode: bool,
|
||||
}
|
||||
|
||||
pub async fn get_qmdl(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
Path(qmdl_name): Path<String>,
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
let qmdl_idx = qmdl_name.trim_end_matches(".qmdl");
|
||||
let qmdl_store = state.qmdl_store_lock.read().await;
|
||||
let (entry_index, entry) = qmdl_store.entry_for_name(qmdl_idx).ok_or((
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("couldn't find qmdl file with name {}", qmdl_idx),
|
||||
))?;
|
||||
let qmdl_file = qmdl_store.open_entry_qmdl(entry_index).await.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("error opening QMDL file: {}", e),
|
||||
)
|
||||
})?;
|
||||
let limited_qmdl_file = qmdl_file.take(entry.qmdl_size_bytes as u64);
|
||||
let qmdl_stream = ReaderStream::new(limited_qmdl_file);
|
||||
|
||||
let headers = [
|
||||
(CONTENT_TYPE, "application/octet-stream"),
|
||||
(CONTENT_LENGTH, &entry.qmdl_size_bytes.to_string()),
|
||||
];
|
||||
let body = Body::from_stream(qmdl_stream);
|
||||
Ok((headers, body).into_response())
|
||||
}
|
||||
|
||||
// Bundles the server's static files (html/css/js) into the binary for easy distribution
|
||||
static STATIC_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/web/build");
|
||||
|
||||
pub async fn serve_static(
|
||||
State(_): State<Arc<ServerState>>,
|
||||
Path(path): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let path = path.trim_start_matches('/');
|
||||
let mime_type = mime_guess::from_path(path).first_or_text_plain();
|
||||
|
||||
match STATIC_DIR.get_file(path) {
|
||||
None => Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
Some(file) => Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_str(mime_type.as_ref()).unwrap(),
|
||||
)
|
||||
.body(Body::from(file.contents()))
|
||||
.unwrap(),
|
||||
}
|
||||
}
|
||||
142
bin/src/stats.rs
142
bin/src/stats.rs
@@ -1,142 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::qmdl_store::ManifestEntry;
|
||||
use crate::server::ServerState;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use log::error;
|
||||
use rayhunter::util::RuntimeMetadata;
|
||||
use serde::Serialize;
|
||||
use tokio::process::Command;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SystemStats {
|
||||
pub disk_stats: DiskStats,
|
||||
pub memory_stats: MemoryStats,
|
||||
pub runtime_metadata: RuntimeMetadata,
|
||||
}
|
||||
|
||||
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?,
|
||||
runtime_metadata: RuntimeMetadata::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[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,17 +0,0 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": [
|
||||
"prettier-plugin-svelte"
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import prettier from "eslint-config-prettier";
|
||||
import js from '@eslint/js';
|
||||
import svelte from 'eslint-plugin-svelte';
|
||||
import globals from 'globals';
|
||||
import ts from 'typescript-eslint';
|
||||
|
||||
export default ts.config(
|
||||
js.configs.recommended,
|
||||
...ts.configs.recommended,
|
||||
...svelte.configs["flat/recommended"],
|
||||
prettier,
|
||||
...svelte.configs['flat/prettier'],
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ["**/*.svelte"],
|
||||
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
parser: ts.parser
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: ["build/", ".svelte-kit/", "dist/"]
|
||||
}
|
||||
);
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"test:unit": "vitest",
|
||||
"test": "npm run test:unit -- --run",
|
||||
"format": "prettier --write .",
|
||||
"lint": "prettier --check . && eslint ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.5",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@types/eslint": "^9.6.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.7.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-svelte": "^2.36.0",
|
||||
"globals": "^15.0.0",
|
||||
"prettier": "^3.3.2",
|
||||
"prettier-plugin-svelte": "^3.2.6",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^3.4.9",
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"vite": "^5.0.3",
|
||||
"vitest": "^2.0.4"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
@import "tailwindcss/base";
|
||||
@import "tailwindcss/components";
|
||||
@import "tailwindcss/utilities"
|
||||
13
bin/web/src/app.d.ts
vendored
13
bin/web/src/app.d.ts
vendored
@@ -1,13 +0,0 @@
|
||||
// See https://svelte.dev/docs/kit/types#app
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -1,12 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,45 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { EventType, parse_finished_report, Severity, type QualitativeWarning } from './analysis.svelte';
|
||||
import { parse_ndjson, type NewlineDeliminatedJson } from './ndjson';
|
||||
|
||||
const SAMPLE_REPORT_NDJSON: NewlineDeliminatedJson = [
|
||||
{ "analyzers": [{ "name": "LTE SIB 6/7 Downgrade", "description": "Tests for LTE cells broadcasting a SIB type 6 and 7 which include 2G/3G frequencies with higher priorities." }, { "name": "IMSI Provided", "description": "Tests whether the UE's IMSI was ever provided to the cell" }, { "name": "Null Cipher", "description": "Tests whether the cell suggests using a null cipher (EEA0)" }, { "name": "Example Analyzer", "description": "Always returns true, if you are seeing this you are either a developer or you are about to have problems." }] },
|
||||
{ "timestamp": "2024-10-08T13:25:43.011689003-07:00", "skipped_message_reasons": ["DecodingError(UperDecodeError(Error { cause: BufferTooShort, msg: \"PerCodec:DecodeError:Requested Bits to decode 3, Remaining bits 1\", context: [] }))"], "analysis": [] },
|
||||
{ "timestamp": "2024-10-08T13:25:43.480872496-07:00", "skipped_message_reasons": [], "analysis": [{ "timestamp": "2024-08-19T03:33:54.318Z", "events": [null, null, null, { "event_type": { "type": "QualitativeWarning", "severity": "Low" }, "message": "TMSI was provided to cell" }] }] },
|
||||
];
|
||||
|
||||
describe('analysis report parsing', () => {
|
||||
it('parses the example analysis', () => {
|
||||
const report = parse_finished_report(SAMPLE_REPORT_NDJSON);
|
||||
expect(report.metadata.analyzers).toEqual([
|
||||
{
|
||||
"name":"LTE SIB 6/7 Downgrade",
|
||||
"description":"Tests for LTE cells broadcasting a SIB type 6 and 7 which include 2G/3G frequencies with higher priorities.",
|
||||
},
|
||||
{
|
||||
"name":"IMSI Provided",
|
||||
"description":"Tests whether the UE's IMSI was ever provided to the cell",
|
||||
},
|
||||
{
|
||||
"name":"Null Cipher",
|
||||
"description":"Tests whether the cell suggests using a null cipher (EEA0)",
|
||||
},
|
||||
{
|
||||
"name":"Example Analyzer",
|
||||
"description":"Always returns true, if you are seeing this you are either a developer or you are about to have problems.",
|
||||
}
|
||||
]);
|
||||
expect(report.rows).toHaveLength(2);
|
||||
expect(report.rows[0].skipped_message_reasons).toHaveLength(1);
|
||||
expect(report.rows[0].analysis).toHaveLength(0);
|
||||
expect(report.rows[1].skipped_message_reasons).toHaveLength(0);
|
||||
expect(report.rows[1].analysis).toHaveLength(1);
|
||||
expect(report.rows[1].analysis[0].events).toHaveLength(1);
|
||||
const event = report.rows[1].analysis[0].events[0];
|
||||
if (event.type === EventType.Warning) {
|
||||
expect(event.severity).toEqual(Severity.Low);
|
||||
} else {
|
||||
throw 'wrong event type';
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,118 +0,0 @@
|
||||
import { parse_ndjson, type NewlineDeliminatedJson } from "./ndjson";
|
||||
import { req } from "./utils.svelte";
|
||||
|
||||
export type AnalysisReport = {
|
||||
metadata: ReportMetadata;
|
||||
rows: AnalysisRow[];
|
||||
statistics: ReportStatistics;
|
||||
};
|
||||
|
||||
export type ReportStatistics = {
|
||||
num_warnings: number;
|
||||
num_informational_logs: number;
|
||||
num_skipped_packets: number;
|
||||
}
|
||||
|
||||
export type ReportMetadata = {
|
||||
analyzers: AnalyzerMetadata[];
|
||||
rayhunter: RayhunterMetadata;
|
||||
};
|
||||
|
||||
export type RayhunterMetadata = {
|
||||
rayhunter_version: string;
|
||||
system_os: string;
|
||||
arch: string;
|
||||
};
|
||||
|
||||
export type AnalyzerMetadata = {
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type AnalysisRow = {
|
||||
timestamp: Date;
|
||||
skipped_message_reasons: string[];
|
||||
analysis: PacketAnalysis[];
|
||||
};
|
||||
|
||||
export type PacketAnalysis = {
|
||||
timestamp: Date;
|
||||
events: Event[];
|
||||
};
|
||||
export type Event = QualitativeWarning | InformationalEvent;
|
||||
export enum EventType {
|
||||
Informational,
|
||||
Warning,
|
||||
}
|
||||
|
||||
export type QualitativeWarning = {
|
||||
type: EventType.Warning;
|
||||
severity: Severity;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export enum Severity {
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
}
|
||||
|
||||
export type InformationalEvent = {
|
||||
type: EventType.Informational;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export function parse_finished_report(report_json: NewlineDeliminatedJson): AnalysisReport {
|
||||
const metadata: ReportMetadata = report_json[0]; // this can be cast directly
|
||||
let num_warnings = 0;
|
||||
let num_informational_logs = 0;
|
||||
let num_skipped_packets = 0;
|
||||
const rows: AnalysisRow[] = report_json.slice(1).map((row_json: any) => {
|
||||
const analysis: PacketAnalysis[] = row_json.analysis.map((analysis_json: any) => {
|
||||
const events: Event[] = analysis_json.events.map((event_json: any): Event | null => {
|
||||
if (event_json === null) {
|
||||
return null;
|
||||
} else if (event_json.event_type === "Informational") {
|
||||
num_informational_logs += 1;
|
||||
return {
|
||||
type: EventType.Informational,
|
||||
message: event_json.message,
|
||||
};
|
||||
} else {
|
||||
num_warnings += 1;
|
||||
return {
|
||||
type: EventType.Warning,
|
||||
severity: event_json.severity === "High" ? Severity.High :
|
||||
event_json.severity === "Medium" ? Severity.Medium : Severity.Low,
|
||||
message: event_json.message,
|
||||
};
|
||||
}
|
||||
})
|
||||
.filter((maybe_event: Event | null) => maybe_event !== null);
|
||||
return {
|
||||
timestamp: analysis_json.timestamp,
|
||||
events,
|
||||
};
|
||||
});
|
||||
num_skipped_packets += row_json.skipped_message_reasons.length;
|
||||
return {
|
||||
timestamp: new Date(row_json.timestamp),
|
||||
skipped_message_reasons: row_json.skipped_message_reasons,
|
||||
analysis,
|
||||
};
|
||||
});
|
||||
return {
|
||||
statistics: {
|
||||
num_informational_logs,
|
||||
num_warnings,
|
||||
num_skipped_packets,
|
||||
},
|
||||
metadata,
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
export async function get_report(name: string): Promise<AnalysisReport> {
|
||||
const report_json = parse_ndjson(await req('GET', `/api/analysis-report/${name}`));
|
||||
return parse_finished_report(report_json);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { AnalysisStatus } from "$lib/analysisManager.svelte";
|
||||
import { EventType } from "$lib/analysis.svelte";
|
||||
import type { ManifestEntry } from "$lib/manifest.svelte";
|
||||
let { entry, onclick }: {
|
||||
entry: ManifestEntry,
|
||||
onclick: () => void,
|
||||
} = $props();
|
||||
|
||||
let summary = $derived.by(() => {
|
||||
if (entry.analysis_status === AnalysisStatus.Queued) {
|
||||
return 'Queued...';
|
||||
} else if (entry.analysis_status === AnalysisStatus.Running) {
|
||||
return 'Running...';
|
||||
} else if (entry.analysis_status === AnalysisStatus.Finished) {
|
||||
if (entry.analysis_report === undefined) {
|
||||
return 'Loading...';
|
||||
} else if (typeof(entry.analysis_report) === 'string') {
|
||||
return entry.analysis_report;
|
||||
} else {
|
||||
let num_warnings = 0;
|
||||
for (let row of entry.analysis_report.rows) {
|
||||
for (let analysis of row.analysis) {
|
||||
for (let event of analysis.events) {
|
||||
if (event.type === EventType.Warning) {
|
||||
num_warnings += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return `${num_warnings} warnings`;
|
||||
}
|
||||
} else {
|
||||
return 'Loading...';
|
||||
}
|
||||
});
|
||||
|
||||
let ready = $derived.by(() => {
|
||||
let finished = entry.analysis_status === AnalysisStatus.Finished;
|
||||
let report_available = entry.analysis_report !== undefined;
|
||||
return finished && report_available;
|
||||
})
|
||||
|
||||
let button_class = $derived(ready ? "text-blue-600 underline" : '');
|
||||
</script>
|
||||
|
||||
<button class={button_class} disabled={!ready} {onclick}>
|
||||
{summary}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
@@ -1,84 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { AnalysisStatus } from "$lib/analysisManager.svelte";
|
||||
import { EventType, type AnalyzerMetadata, type ReportMetadata, type AnalysisRow, type AnalysisReport } from "$lib/analysis.svelte";
|
||||
import type { ManifestEntry } from "$lib/manifest.svelte";
|
||||
let { report }: {
|
||||
report: AnalysisReport,
|
||||
} = $props();
|
||||
|
||||
const date_formatter = new Intl.DateTimeFormat(undefined, {
|
||||
timeStyle: "long",
|
||||
dateStyle: "short",
|
||||
});
|
||||
|
||||
const skipped_messages: Map<string, number> = $derived.by(() => {
|
||||
let map = new Map();
|
||||
for (const row of report.rows) {
|
||||
for (const message of row.skipped_message_reasons) {
|
||||
let count = map.get(message);
|
||||
if (count === undefined) {
|
||||
count = 0;
|
||||
}
|
||||
map.set(message, count + 1);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
});
|
||||
</script>
|
||||
|
||||
<p class="text-lg underline">Warnings and Informational Logs</p>
|
||||
{#if report.statistics.num_warnings === 0 && report.statistics.num_informational_logs === 0}
|
||||
<p>Nothing to show!</p>
|
||||
{:else}
|
||||
<table class="table-auto text-left border">
|
||||
<thead class="p-2">
|
||||
<tr class="bg-gray-300">
|
||||
<th scope="col">Timestamp</th>
|
||||
<th scope="col">Warning</th>
|
||||
<th scope="col">Severity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each report.rows as row, row_idx}
|
||||
{#each row.analysis as analysis}
|
||||
{@const parsed_date = new Date(analysis.timestamp)}
|
||||
{#each analysis.events.filter(e => e !== null) as event}
|
||||
<tr class="even:bg-gray-200 border-b">
|
||||
{#if event.type === EventType.Warning}
|
||||
{@const severity = ['Low', 'Medium', 'High'][event.severity]}
|
||||
{@const severity_class = ['bg-red-200', 'bg-red-400', 'bg-red-600'][event.severity]}
|
||||
<th class="p-2">{date_formatter.format(parsed_date)}</th>
|
||||
<td class="p-2">{event.message}</td>
|
||||
<td class="p-2 {severity_class}">{severity}</td>
|
||||
{:else if event.type === EventType.Informational}
|
||||
<th class="p-2">{date_formatter.format(parsed_date)}</th>
|
||||
<td class="p-2">{event.message}</td>
|
||||
<td class="p-2">Info</td>
|
||||
{/if}
|
||||
</tr>
|
||||
{/each}
|
||||
{/each}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
{#if report.statistics.num_skipped_packets > 0}
|
||||
<p class="text-lg underline">Unparsed Messages</p>
|
||||
<p>These are due to a limitation or bug in Rayhunter's parser, and aren't ususally a problem.</p>
|
||||
<table class="table-auto text-left border">
|
||||
<thead class="p-2">
|
||||
<tr class="bg-gray-300">
|
||||
<th scope="col"># of messages affected</th>
|
||||
<th scope="col">Reason/Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each skipped_messages.entries() as [message, count]}
|
||||
<tr class="even:bg-gray-200 border-b">
|
||||
<td>{count}</td>
|
||||
<td>{message}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
@@ -1,44 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { AnalysisStatus } from "$lib/analysisManager.svelte";
|
||||
import { EventType, type AnalyzerMetadata, type ReportMetadata, type AnalysisRow } from "$lib/analysis.svelte";
|
||||
import type { ManifestEntry } from "$lib/manifest.svelte";
|
||||
import AnalysisTable from "./AnalysisTable.svelte";
|
||||
let { entry }: {
|
||||
entry: ManifestEntry,
|
||||
} = $props();
|
||||
|
||||
const date_formatter = new Intl.DateTimeFormat(undefined, {
|
||||
timeStyle: "long",
|
||||
dateStyle: "short",
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="container max-h-96 overflow-auto">
|
||||
{#if entry.analysis_report === undefined}
|
||||
<p>Report unavailable, try refreshing.</p>
|
||||
{:else if typeof(entry.analysis_report) === 'string'}
|
||||
<p>Error getting analysis report: {entry.analysis_report}</p>
|
||||
{:else}
|
||||
{@const metadata: ReportMetadata = entry.analysis_report.metadata}
|
||||
<div class="flex flex-col pl-2 pr-10 w-full">
|
||||
{#if entry.analysis_report.rows.length > 0}
|
||||
<AnalysisTable report={entry.analysis_report} />
|
||||
{:else}
|
||||
<p>No warnings to display!</p>
|
||||
{/if}
|
||||
<div>
|
||||
<p class="text-lg underline">Metadata</p>
|
||||
{#if metadata !== undefined && metadata.rayhunter !== undefined}
|
||||
<p>Analysis by Rayhunter version {metadata.rayhunter.rayhunter_version}</p>
|
||||
<p><b>Device system OS:</b> {metadata.rayhunter.system_os}</p>
|
||||
<p class="text-lg underline">Analyzers</p>
|
||||
{#each metadata.analyzers as analyzer}
|
||||
<p><b>{analyzer.name}:</b> {analyzer.description}</p>
|
||||
{/each}
|
||||
{:else}
|
||||
<p>N/A (analysis generated by an older version of rayhunter)</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,23 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { req } from "$lib/utils.svelte";
|
||||
import DeleteButton from "./DeleteButton.svelte";
|
||||
import RecordingControls from "./RecordingControls.svelte";
|
||||
let { server_is_recording }: {
|
||||
server_is_recording: boolean;
|
||||
} = $props();
|
||||
|
||||
function confirmDelete() {
|
||||
if (window.confirm(`Permanently delete ALL entries?`)) {
|
||||
req('POST', '/api/delete-all-recordings')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-row gap-2">
|
||||
<RecordingControls {server_is_recording} />
|
||||
<DeleteButton
|
||||
text="Delete ALL Entries"
|
||||
prompt={`Are you sure you want to delete ALL entries?`}
|
||||
url={`/api/delete-all-recordings`}
|
||||
/>
|
||||
</div>
|
||||
@@ -1,28 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { ManifestEntry } from "$lib/manifest.svelte";
|
||||
import { req } from "$lib/utils.svelte";
|
||||
let { text, url, prompt }: {
|
||||
text?: string,
|
||||
url: string,
|
||||
prompt: string,
|
||||
} = $props();
|
||||
|
||||
function confirmDelete() {
|
||||
if (window.confirm(prompt)) {
|
||||
req('POST', url)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<button class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded-md flex flex-row" onclick={confirmDelete} aria-label="delete">
|
||||
<p>{text}</p>
|
||||
<svg
|
||||
style="width:24px;height:24px"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="white"
|
||||
d="M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
@@ -1,16 +0,0 @@
|
||||
<script lang="ts">
|
||||
let { url, text }: {
|
||||
url: string;
|
||||
text: string;
|
||||
} = $props();
|
||||
|
||||
function download() {
|
||||
window.location.href = url;
|
||||
}
|
||||
</script>
|
||||
|
||||
<button class="text-blue-600 flex flex-row underline" onclick={download}>
|
||||
{text} <svg class="fill-current w-4 h-4 m-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
<path d="M13 8V2H7v6H2l8 8 8-8h-5zM0 18h20v2H0v-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
@@ -1,32 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Manifest, ManifestEntry } from "$lib/manifest.svelte";
|
||||
import TableRow from "./ManifestTableRow.svelte";
|
||||
interface Props {
|
||||
entries: ManifestEntry[];
|
||||
current_entry: ManifestEntry | undefined;
|
||||
}
|
||||
let { entries, current_entry }: Props = $props();
|
||||
</script>
|
||||
|
||||
<table class="table-auto text-left border">
|
||||
<thead class="p-2">
|
||||
<tr class="bg-gray-300">
|
||||
<th class='p-2' scope="col">Name</th>
|
||||
<th class='p-2' scope="col">Date Started</th>
|
||||
<th class='p-2' scope="col">Date of Last Message</th>
|
||||
<th class='p-2' scope="col">Size (bytes)</th>
|
||||
<th class='p-2' scope="col">PCAP</th>
|
||||
<th class='p-2' scope="col">QMDL</th>
|
||||
<th class='p-2' scope="col">Analysis</th>
|
||||
<th class='p-2' scope="col">Delete</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if current_entry !== undefined}
|
||||
<TableRow entry={current_entry} current={true} i={0} />
|
||||
{/if}
|
||||
{#each entries as entry, i}
|
||||
<TableRow {entry} current={false} {i} />
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -1,56 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { ManifestEntry } from "$lib/manifest.svelte";
|
||||
import DownloadLink from '$lib/components/DownloadLink.svelte';
|
||||
import DeleteButton from "$lib/components/DeleteButton.svelte";
|
||||
import AnalysisStatus from "./AnalysisStatus.svelte";
|
||||
import AnalysisView from "./AnalysisView.svelte";
|
||||
let { entry, current, i }: {
|
||||
entry: ManifestEntry;
|
||||
current: boolean;
|
||||
i: number
|
||||
} = $props();
|
||||
|
||||
// passing `undefined` as the locale uses the browser default
|
||||
const date_formatter = new Intl.DateTimeFormat(undefined, {
|
||||
timeStyle: "long",
|
||||
dateStyle: "short",
|
||||
});
|
||||
let alternating_row_color = $derived(i % 2 == 0 ? "bg-white" : "bg-gray-100");
|
||||
let status_row_color = $derived.by(() => {
|
||||
const num_warnings = entry.get_num_warnings();
|
||||
if (num_warnings !== undefined && num_warnings > 0) {
|
||||
return "bg-red-100";
|
||||
}
|
||||
return current ? "bg-green-100" : alternating_row_color
|
||||
});
|
||||
let analysis_visible = $state(false);
|
||||
function toggle_analysis_visibility() {
|
||||
analysis_visible = !analysis_visible;
|
||||
}
|
||||
</script>
|
||||
|
||||
<tr class="{status_row_color}">
|
||||
<th class="font-bold p-2 bg-blue-100" scope='row'>{entry.name}</th>
|
||||
<td class="p-2">{date_formatter.format(entry.start_time)}</td>
|
||||
<td class="p-2">{date_formatter.format(entry.last_message_time)}</td>
|
||||
<td class="p-2">{entry.qmdl_size_bytes}</td>
|
||||
<td class="p-2"><DownloadLink url={entry.get_pcap_url()} text="pcap" /></td>
|
||||
<td class="p-2"><DownloadLink url={entry.get_qmdl_url()} text="qmdl" /></td>
|
||||
<td class="p-2"><AnalysisStatus onclick={toggle_analysis_visibility} entry={entry} /></td>
|
||||
{#if current}
|
||||
<td class="p-2"></td>
|
||||
{:else}
|
||||
<td class="p-2">
|
||||
<DeleteButton
|
||||
prompt={`Are you sure you want to delete entry ${entry.name}?`}
|
||||
url={entry.get_delete_url()}
|
||||
/>
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
<tr class="{alternating_row_color} border-b {analysis_visible ? '' : 'hidden'}">
|
||||
<td class="font-bold p-2 bg-blue-100"></td>
|
||||
<td class="border-t border-dashed p-2" colspan="7">
|
||||
<AnalysisView {entry} />
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1,37 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { req } from "$lib/utils.svelte";
|
||||
let { server_is_recording }: {
|
||||
server_is_recording: boolean;
|
||||
} = $props();
|
||||
|
||||
let client_set_recording = $state(server_is_recording);
|
||||
let waiting_for_server = $derived(client_set_recording !== server_is_recording);
|
||||
|
||||
async function start_recording() {
|
||||
await req('POST', '/api/start-recording');
|
||||
client_set_recording = true;
|
||||
}
|
||||
|
||||
async function stop_recording() {
|
||||
await req('POST', '/api/stop-recording');
|
||||
client_set_recording = false;
|
||||
}
|
||||
|
||||
const stop_recording_classes = "bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded-md";
|
||||
const start_recording_classes = "bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-md";
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{#if waiting_for_server}
|
||||
<button class={server_is_recording ? stop_recording_classes : start_recording_classes}>
|
||||
{server_is_recording ? "Stopping..." : "Starting..."}
|
||||
</button>
|
||||
{:else if server_is_recording}
|
||||
<button class={stop_recording_classes} onclick={stop_recording}>Stop Recording</button>
|
||||
{:else}
|
||||
<button class={start_recording_classes} onclick={start_recording}>Start Recording</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
@@ -1,37 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { type SystemStats } from "$lib/systemStats";
|
||||
let { stats }: {
|
||||
stats: SystemStats;
|
||||
} = $props();
|
||||
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<p class="text-xl">System Stats</p>
|
||||
<table class="table-auto border">
|
||||
<tbody>
|
||||
<tr class="border">
|
||||
<th class="border">
|
||||
Rayhunter version
|
||||
</th>
|
||||
<td class="border">{stats.runtime_metadata.rayhunter_version}</td>
|
||||
</tr>
|
||||
<tr class="border">
|
||||
<th class="border">
|
||||
Storage
|
||||
</th>
|
||||
<td class="border">
|
||||
{stats.disk_stats.used_percent} used ({stats.disk_stats.used_size} / {stats.disk_stats.available_size})
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b">
|
||||
<th class="border">
|
||||
Memory (RAM)
|
||||
</th>
|
||||
<td class="border">
|
||||
Free: {stats.memory_stats.free}, Used: {stats.memory_stats.used}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -1,26 +0,0 @@
|
||||
export interface SystemStats {
|
||||
disk_stats: DiskStats;
|
||||
memory_stats: MemoryStats;
|
||||
runtime_metadata: RuntimeMetadata;
|
||||
}
|
||||
|
||||
export interface RuntimeMetadata {
|
||||
rayhunter_version: string,
|
||||
system_os: string,
|
||||
arch: string,
|
||||
}
|
||||
|
||||
export interface DiskStats {
|
||||
partition: string,
|
||||
total_size: string,
|
||||
used_size: string,
|
||||
available_size: string,
|
||||
used_percent: string,
|
||||
mounted_on: string,
|
||||
}
|
||||
|
||||
export interface MemoryStats {
|
||||
total: string,
|
||||
used: string,
|
||||
free: string,
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Manifest } from "./manifest.svelte";
|
||||
import type { SystemStats } from "./systemStats";
|
||||
|
||||
export async function req(method: string, url: string): Promise<string> {
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
});
|
||||
const body = await response.text();
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
return body;
|
||||
} else {
|
||||
throw new Error(body);
|
||||
}
|
||||
}
|
||||
|
||||
export async function get_manifest(): Promise<Manifest> {
|
||||
const manifest_json = JSON.parse(await req('GET', '/api/qmdl-manifest'));
|
||||
return new Manifest(manifest_json);
|
||||
}
|
||||
|
||||
export async function get_system_stats(): Promise<SystemStats> {
|
||||
return JSON.parse(await req('GET', '/api/system-stats'));
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
@@ -1,42 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { ManifestEntry } from "$lib/manifest.svelte";
|
||||
import { get_manifest, get_system_stats } from "$lib/utils.svelte";
|
||||
import ManifestTable from "$lib/components/ManifestTable.svelte";
|
||||
import type { SystemStats } from "$lib/systemStats";
|
||||
import { AnalysisManager } from "$lib/analysisManager.svelte";
|
||||
import SystemStatsTable from "$lib/components/SystemStatsTable.svelte";
|
||||
import ControlBar from "$lib/components/ControlBar.svelte";
|
||||
|
||||
let manager: AnalysisManager = new AnalysisManager();
|
||||
let loaded = $state(false);
|
||||
let recording = $state(false);
|
||||
let entries: ManifestEntry[] = $state([]);
|
||||
let current_entry: ManifestEntry | undefined = $state(undefined);
|
||||
let system_stats: SystemStats | undefined = $state(undefined);
|
||||
$effect(() => {
|
||||
const interval = setInterval(async () => {
|
||||
await manager.update();
|
||||
let new_manifest = await get_manifest();
|
||||
await new_manifest.set_analysis_status(manager);
|
||||
entries = new_manifest.entries;
|
||||
current_entry = new_manifest.current_entry;
|
||||
recording = current_entry !== undefined;
|
||||
|
||||
system_stats = await get_system_stats();
|
||||
loaded = true;
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
})
|
||||
</script>
|
||||
|
||||
<h1 class="ml-8 mt-8 text-4xl font-extrabold">Rayhunter Dashboard</h1>
|
||||
<div class="p-8 flex flex-col gap-2">
|
||||
{#if loaded}
|
||||
<ControlBar server_is_recording={recording} />
|
||||
<SystemStatsTable stats={system_stats!} />
|
||||
<ManifestTable entries={entries} current_entry={current_entry} />
|
||||
{:else}
|
||||
<p>Loading...</p>
|
||||
{/if}
|
||||
</div>
|
||||
4
bin/web/static/pico.min.css
vendored
4
bin/web/static/pico.min.css
vendored
File diff suppressed because one or more lines are too long
@@ -1,15 +0,0 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
|
||||
export default {
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
// default options are shown. On some platforms
|
||||
// these options are set automatically — see below
|
||||
pages: 'build',
|
||||
assets: 'build',
|
||||
fallback: undefined,
|
||||
precompress: false,
|
||||
strict: true
|
||||
})
|
||||
}
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
export default {
|
||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'rayhunter-blue': '#4e4eb1',
|
||||
'rayhunter-dark-blue': '#3f3da0',
|
||||
'rayhunter-green': '#94ea18'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
plugins: []
|
||||
} as Config;
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||
}
|
||||
@@ -3,3 +3,7 @@ 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};
|
||||
@@ -18,14 +17,12 @@ use tokio::sync::mpsc::Receiver;
|
||||
use tokio::sync::{RwLock, RwLockWriteGuard};
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
use crate::dummy_analyzer::TestAnalyzer;
|
||||
use crate::qmdl_store::RecordingStore;
|
||||
use crate::server::ServerState;
|
||||
|
||||
pub struct AnalysisWriter {
|
||||
writer: BufWriter<File>,
|
||||
harness: Harness,
|
||||
bytes_written: usize,
|
||||
}
|
||||
|
||||
// We write our analysis results to a file immediately to minimize the amount of
|
||||
@@ -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,22 +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.
|
||||
// to the analysis file, returning the whether any warnings were detected
|
||||
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?;
|
||||
) -> 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(())
|
||||
@@ -80,10 +76,15 @@ impl AnalysisWriter {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
@@ -130,62 +131,52 @@ async fn finish_running_analysis(analysis_status_lock: Arc<RwLock<AnalysisStatus
|
||||
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))?;
|
||||
.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(())
|
||||
}
|
||||
@@ -195,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 {
|
||||
@@ -205,10 +196,9 @@ pub fn run_analysis_thread(
|
||||
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
|
||||
perform_analysis(&name, qmdl_store_lock.clone(), &analyzer_config).await
|
||||
{
|
||||
error!("failed to analyze {}: {}", name, err);
|
||||
error!("failed to analyze {name}: {err}");
|
||||
}
|
||||
finish_running_analysis(analysis_status_lock.clone()).await;
|
||||
}
|
||||
@@ -223,6 +213,16 @@ pub fn run_analysis_thread(
|
||||
});
|
||||
}
|
||||
|
||||
#[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)> {
|
||||
@@ -239,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>,
|
||||
@@ -269,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 },
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
use crate::config;
|
||||
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
|
||||
use crate::display::DisplayState;
|
||||
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
|
||||
use async_trait::async_trait;
|
||||
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
const FB_PATH: &str = "/dev/fb0";
|
||||
@@ -11,6 +12,7 @@ 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?
|
||||
@@ -20,30 +22,30 @@ impl GenericFramebuffer for Framebuffer {
|
||||
}
|
||||
}
|
||||
|
||||
fn write_buffer(&mut self, buffer: &[(u8, u8, u8)]) {
|
||||
let mut raw_buffer = Vec::new();
|
||||
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;
|
||||
let mut rgb565: u16 = (r as u16 & 0b11111000) << 8;
|
||||
rgb565 |= (g as u16 & 0b11111100) << 3;
|
||||
rgb565 |= (b as u16) >> 3;
|
||||
raw_buffer.extend(rgb565.to_le_bytes());
|
||||
}
|
||||
|
||||
std::fs::write(FB_PATH, &raw_buffer).unwrap();
|
||||
tokio::fs::write(FB_PATH, &raw_buffer).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_ui(
|
||||
task_tracker: &TaskTracker,
|
||||
config: &config::Config,
|
||||
ui_shutdown_rx: oneshot::Receiver<()>,
|
||||
shutdown_token: CancellationToken,
|
||||
ui_update_rx: Receiver<DisplayState>,
|
||||
) {
|
||||
generic_framebuffer::update_ui(
|
||||
task_tracker,
|
||||
config,
|
||||
Framebuffer,
|
||||
ui_shutdown_rx,
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
use log::info;
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
use crate::config;
|
||||
use crate::display::{tplink_framebuffer, tplink_onebit, DisplayState};
|
||||
use crate::display::{DisplayState, tplink_framebuffer, tplink_onebit};
|
||||
|
||||
use std::fs;
|
||||
|
||||
pub fn update_ui(
|
||||
task_tracker: &TaskTracker,
|
||||
config: &config::Config,
|
||||
ui_shutdown_rx: oneshot::Receiver<()>,
|
||||
shutdown_token: CancellationToken,
|
||||
ui_update_rx: Receiver<DisplayState>,
|
||||
) {
|
||||
let display_level = config.ui_level;
|
||||
@@ -19,11 +19,13 @@ pub fn update_ui(
|
||||
info!("Invisible mode, not spawning UI.");
|
||||
}
|
||||
|
||||
// Since this is a one-time check at startup, using sync is acceptable
|
||||
// The alternative would be to make the entire initialization async
|
||||
if fs::exists(tplink_onebit::OLED_PATH).unwrap_or_default() {
|
||||
info!("detected one-bit display");
|
||||
tplink_onebit::update_ui(task_tracker, config, ui_shutdown_rx, ui_update_rx)
|
||||
tplink_onebit::update_ui(task_tracker, config, shutdown_token, ui_update_rx)
|
||||
} else {
|
||||
info!("fallback to framebuffer");
|
||||
tplink_framebuffer::update_ui(task_tracker, config, ui_shutdown_rx, ui_update_rx)
|
||||
tplink_framebuffer::update_ui(task_tracker, config, shutdown_token, ui_update_rx)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
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::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
|
||||
use crate::display::DisplayState;
|
||||
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
|
||||
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
const FB_PATH: &str = "/dev/fb0";
|
||||
@@ -24,6 +25,7 @@ struct fb_fillrect {
|
||||
rop: u32,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GenericFramebuffer for Framebuffer {
|
||||
fn dimensions(&self) -> Dimensions {
|
||||
// TODO actually poll for this, maybe w/ fbset?
|
||||
@@ -33,12 +35,12 @@ impl GenericFramebuffer for Framebuffer {
|
||||
}
|
||||
}
|
||||
|
||||
fn write_buffer(&mut self, buffer: &[(u8, u8, u8)]) {
|
||||
async fn write_buffer(&mut self, buffer: Vec<(u8, u8, u8)>) {
|
||||
// for how to write to the buffer, consult M7350v5_en_gpl/bootable/recovery/recovery_color_oled.c
|
||||
let dimensions = self.dimensions();
|
||||
let width = dimensions.width;
|
||||
let height = buffer.len() as u32 / width;
|
||||
let mut f = File::options().write(true).open(FB_PATH).unwrap();
|
||||
let mut f = OpenOptions::new().write(true).open(FB_PATH).await.unwrap();
|
||||
let mut arg = fb_fillrect {
|
||||
dx: 0,
|
||||
dy: 0,
|
||||
@@ -48,17 +50,18 @@ impl GenericFramebuffer for Framebuffer {
|
||||
rop: 0,
|
||||
};
|
||||
|
||||
let mut raw_buffer = Vec::new();
|
||||
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;
|
||||
let mut rgb565: u16 = (r as u16 & 0b11111000) << 8;
|
||||
rgb565 |= (g as u16 & 0b11111100) << 3;
|
||||
rgb565 |= (b as u16) >> 3;
|
||||
// note: big-endian!
|
||||
raw_buffer.extend(rgb565.to_be_bytes());
|
||||
}
|
||||
|
||||
f.write_all(&raw_buffer).unwrap();
|
||||
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(),
|
||||
@@ -68,7 +71,7 @@ impl GenericFramebuffer for Framebuffer {
|
||||
);
|
||||
|
||||
if res < 0 {
|
||||
panic!("failed to send FBIORECT_DISPLAY ioctl, {}", res);
|
||||
panic!("failed to send FBIORECT_DISPLAY ioctl, {res}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,14 +80,14 @@ impl GenericFramebuffer for Framebuffer {
|
||||
pub fn update_ui(
|
||||
task_tracker: &TaskTracker,
|
||||
config: &config::Config,
|
||||
ui_shutdown_rx: oneshot::Receiver<()>,
|
||||
shutdown_token: CancellationToken,
|
||||
ui_update_rx: Receiver<DisplayState>,
|
||||
) {
|
||||
generic_framebuffer::update_ui(
|
||||
task_tracker,
|
||||
config,
|
||||
Framebuffer,
|
||||
ui_shutdown_rx,
|
||||
shutdown_token,
|
||||
ui_update_rx,
|
||||
)
|
||||
}
|
||||
@@ -6,12 +6,9 @@ use crate::display::DisplayState;
|
||||
|
||||
use log::{error, info};
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::sync::oneshot::error::TryRecvError;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
use std::fs;
|
||||
use std::thread::sleep;
|
||||
use std::time::Duration;
|
||||
|
||||
pub const OLED_PATH: &str = "/sys/class/display/oled/oled_buffer";
|
||||
@@ -114,7 +111,7 @@ const STATUS_WARNING: &[u8] = pixelart! {
|
||||
pub fn update_ui(
|
||||
task_tracker: &TaskTracker,
|
||||
config: &config::Config,
|
||||
mut ui_shutdown_rx: oneshot::Receiver<()>,
|
||||
shutdown_token: CancellationToken,
|
||||
mut ui_update_rx: Receiver<DisplayState>,
|
||||
) {
|
||||
let display_level = config.ui_level;
|
||||
@@ -122,23 +119,19 @@ pub fn update_ui(
|
||||
info!("Invisible mode, not spawning UI.");
|
||||
}
|
||||
|
||||
task_tracker.spawn_blocking(move || {
|
||||
task_tracker.spawn(async move {
|
||||
let mut pixels = STATUS_SMILING;
|
||||
|
||||
loop {
|
||||
match ui_shutdown_rx.try_recv() {
|
||||
Ok(_) => {
|
||||
info!("received UI shutdown");
|
||||
break;
|
||||
}
|
||||
Err(TryRecvError::Empty) => {}
|
||||
Err(e) => panic!("error receiving shutdown message: {e}"),
|
||||
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,
|
||||
Ok(DisplayState::WarningDetected { .. }) => pixels = STATUS_WARNING,
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
|
||||
Err(e) => {
|
||||
error!("error receiving framebuffer update message: {e}");
|
||||
@@ -147,13 +140,13 @@ pub fn update_ui(
|
||||
|
||||
// we write the status every second because it may have been overwritten through menu
|
||||
// navigation.
|
||||
if display_level != 0 {
|
||||
if let Err(e) = fs::write(OLED_PATH, &pixels) {
|
||||
error!("failed to write to display: {e}");
|
||||
}
|
||||
if display_level != 0
|
||||
&& let Err(e) = tokio::fs::write(OLED_PATH, pixels).await
|
||||
{
|
||||
error!("failed to write to display: {e}");
|
||||
}
|
||||
|
||||
sleep(Duration::from_millis(1000));
|
||||
tokio::time::sleep(Duration::from_millis(1000)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,53 @@
|
||||
mod analysis;
|
||||
mod battery;
|
||||
mod config;
|
||||
mod diag;
|
||||
mod display;
|
||||
mod dummy_analyzer;
|
||||
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::{get_qmdl, serve_static, ServerState};
|
||||
use crate::stats::get_system_stats;
|
||||
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::{
|
||||
get_analysis_status, run_analysis_thread, start_analysis, AnalysisCtrlMessage, AnalysisStatus,
|
||||
AnalysisCtrlMessage, AnalysisStatus, get_analysis_status, run_analysis_thread, start_analysis,
|
||||
};
|
||||
use axum::Router;
|
||||
use axum::response::Redirect;
|
||||
use axum::routing::{get, post};
|
||||
use axum::Router;
|
||||
use diag::{
|
||||
delete_all_recordings, delete_recording, get_analysis_report, start_recording, stop_recording,
|
||||
DiagDeviceCtrlMessage,
|
||||
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_qmdl_manifest;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use stats::get_log;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::select;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::sync::mpsc::{self, Sender};
|
||||
use tokio::sync::{oneshot, RwLock};
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
type AppRouter = Router<Arc<ServerState>>;
|
||||
@@ -45,8 +56,10 @@ 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))
|
||||
@@ -54,6 +67,12 @@ fn get_router() -> AppRouter {
|
||||
.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))
|
||||
}
|
||||
@@ -63,31 +82,26 @@ fn get_router() -> AppRouter {
|
||||
// (i.e. user hit ctrl+c)
|
||||
async fn run_server(
|
||||
task_tracker: &TaskTracker,
|
||||
config: &config::Config,
|
||||
state: Arc<ServerState>,
|
||||
server_shutdown_rx: oneshot::Receiver<()>,
|
||||
shutdown_token: CancellationToken,
|
||||
) -> JoinHandle<()> {
|
||||
info!("spinning up server");
|
||||
let app = get_router().with_state(state);
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], config.port));
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], state.config.port));
|
||||
let listener = TcpListener::bind(&addr).await.unwrap();
|
||||
let app = get_router().with_state(state);
|
||||
|
||||
task_tracker.spawn(async move {
|
||||
info!("The orca is hunting for stingrays...");
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(server_shutdown_signal(server_shutdown_rx))
|
||||
.with_graceful_shutdown(shutdown_token.cancelled_owned())
|
||||
.await
|
||||
.unwrap();
|
||||
})
|
||||
}
|
||||
|
||||
async fn server_shutdown_signal(server_shutdown_rx: oneshot::Receiver<()>) {
|
||||
server_shutdown_rx.await.unwrap();
|
||||
info!("Server received shutdown signal, exiting...");
|
||||
}
|
||||
|
||||
// Loads a RecordingStore if one exists, and if not, only create one if we're
|
||||
// not in debug mode. If we fail to parse the manifest AND we're not in debug
|
||||
// mode, try to recover by making a new (empty) manifest in the same directory.
|
||||
// mode, try to recover the manifest from the existing QMDL files
|
||||
async fn init_qmdl_store(config: &config::Config) -> Result<RecordingStore, RayhunterError> {
|
||||
let store_exists = RecordingStore::exists(&config.qmdl_store_path).await?;
|
||||
if config.debug_mode {
|
||||
@@ -102,9 +116,9 @@ async fn init_qmdl_store(config: &config::Config) -> Result<RecordingStore, Rayh
|
||||
match RecordingStore::load(&config.qmdl_store_path).await {
|
||||
Ok(store) => Ok(store),
|
||||
Err(RecordingStoreError::ParseManifestError(err)) => {
|
||||
error!("failed to parse QMDL manifest: {}", err);
|
||||
info!("creating new empty manifest...");
|
||||
Ok(RecordingStore::create(&config.qmdl_store_path).await?)
|
||||
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()),
|
||||
}
|
||||
@@ -116,57 +130,70 @@ async fn init_qmdl_store(config: &config::Config) -> Result<RecordingStore, Rayh
|
||||
// Start a thread that'll track when user hits ctrl+c. When that happens,
|
||||
// trigger various cleanup tasks, including sending signals to other threads to
|
||||
// shutdown
|
||||
fn run_ctrl_c_thread(
|
||||
fn run_shutdown_thread(
|
||||
task_tracker: &TaskTracker,
|
||||
diag_device_sender: Sender<DiagDeviceCtrlMessage>,
|
||||
server_shutdown_tx: oneshot::Sender<()>,
|
||||
maybe_ui_shutdown_tx: Option<oneshot::Sender<()>>,
|
||||
shutdown_token: CancellationToken,
|
||||
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!");
|
||||
}
|
||||
info!("create shutdown thread");
|
||||
|
||||
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");
|
||||
task_tracker.spawn(async move {
|
||||
select! {
|
||||
res = tokio::signal::ctrl_c() => {
|
||||
if let Err(err) = res {
|
||||
error!("Unable to listen for shutdown signal: {err}");
|
||||
}
|
||||
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);
|
||||
}
|
||||
_ = 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]
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> Result<(), RayhunterError> {
|
||||
env_logger::init();
|
||||
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();
|
||||
let config = parse_config(&args.config_path)?;
|
||||
|
||||
loop {
|
||||
let config = parse_config(&args.config_path).await?;
|
||||
if !run_with_config(&args, config).await? {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_with_config(
|
||||
args: &config::Args,
|
||||
config: config::Config,
|
||||
) -> Result<bool, RayhunterError> {
|
||||
// TaskTrackers give us an interface to spawn tokio threads, and then
|
||||
// eventually await all of them ending
|
||||
let task_tracker = TaskTracker::new();
|
||||
@@ -175,14 +202,21 @@ async fn main() -> Result<(), RayhunterError> {
|
||||
let store = init_qmdl_store(&config).await?;
|
||||
let analysis_status = AnalysisStatus::new(&store);
|
||||
let qmdl_store_lock = Arc::new(RwLock::new(store));
|
||||
let (tx, rx) = mpsc::channel::<DiagDeviceCtrlMessage>(1);
|
||||
let (diag_tx, diag_rx) = mpsc::channel::<DiagDeviceCtrlMessage>(1);
|
||||
let (ui_update_tx, ui_update_rx) = mpsc::channel::<display::DisplayState>(1);
|
||||
let (analysis_tx, analysis_rx) = mpsc::channel::<AnalysisCtrlMessage>(5);
|
||||
let mut maybe_ui_shutdown_tx = None;
|
||||
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 {
|
||||
let (ui_shutdown_tx, ui_shutdown_rx) = oneshot::channel();
|
||||
maybe_ui_shutdown_tx = Some(ui_shutdown_tx);
|
||||
let mut dev = DiagDevice::new()
|
||||
info!("Using configuration for device: {0:?}", config.device);
|
||||
let mut dev = DiagDevice::new(&config.device)
|
||||
.await
|
||||
.map_err(RayhunterError::DiagInitError)?;
|
||||
dev.config_logs()
|
||||
@@ -193,47 +227,84 @@ async fn main() -> Result<(), RayhunterError> {
|
||||
run_diag_read_thread(
|
||||
&task_tracker,
|
||||
dev,
|
||||
rx,
|
||||
diag_rx,
|
||||
diag_tx.clone(),
|
||||
ui_update_tx.clone(),
|
||||
qmdl_store_lock.clone(),
|
||||
config.enable_dummy_analyzer,
|
||||
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");
|
||||
display::update_ui(&task_tracker, &config, ui_shutdown_rx, ui_update_rx);
|
||||
|
||||
let update_ui = match &config.device {
|
||||
Device::Orbic => display::orbic::update_ui,
|
||||
Device::Tplink => display::tplink::update_ui,
|
||||
Device::Tmobile => display::tmobile::update_ui,
|
||||
Device::Wingtech => display::wingtech::update_ui,
|
||||
Device::Pinephone => display::headless::update_ui,
|
||||
Device::Uz801 => display::uz801::update_ui,
|
||||
};
|
||||
update_ui(&task_tracker, &config, 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 (server_shutdown_tx, server_shutdown_rx) = oneshot::channel::<()>();
|
||||
info!("create shutdown thread");
|
||||
|
||||
let analysis_status_lock = Arc::new(RwLock::new(analysis_status));
|
||||
run_analysis_thread(
|
||||
&task_tracker,
|
||||
analysis_rx,
|
||||
qmdl_store_lock.clone(),
|
||||
analysis_status_lock.clone(),
|
||||
config.enable_dummy_analyzer,
|
||||
config.analyzers.clone(),
|
||||
);
|
||||
run_ctrl_c_thread(
|
||||
|
||||
run_shutdown_thread(
|
||||
&task_tracker,
|
||||
tx.clone(),
|
||||
server_shutdown_tx,
|
||||
maybe_ui_shutdown_tx,
|
||||
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: tx,
|
||||
ui_update_sender: ui_update_tx,
|
||||
debug_mode: config.debug_mode,
|
||||
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, &config, state, server_shutdown_rx).await;
|
||||
run_server(&task_tracker, state, shutdown_token.clone()).await;
|
||||
|
||||
task_tracker.close();
|
||||
task_tracker.wait().await;
|
||||
|
||||
info!("see you space cowboy...");
|
||||
Ok(())
|
||||
Ok(restart_token.is_cancelled())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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(())
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
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 std::path::{Path, PathBuf};
|
||||
use thiserror::Error;
|
||||
use tokio::{
|
||||
fs::{self, try_exists, File, OpenOptions},
|
||||
fs::{self, File, OpenOptions, try_exists},
|
||||
io::AsyncWriteExt,
|
||||
};
|
||||
|
||||
@@ -41,37 +46,58 @@ 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>>,
|
||||
pub qmdl_size_bytes: usize,
|
||||
pub analysis_size_bytes: usize,
|
||||
/// 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 = Local::now();
|
||||
let now = rayhunter::clock::get_adjusted_now();
|
||||
let metadata = RuntimeMetadata::new();
|
||||
ManifestEntry {
|
||||
name: format!("{}", now.timestamp()),
|
||||
start_time: now,
|
||||
last_message_time: None,
|
||||
qmdl_size_bytes: 0,
|
||||
analysis_size_bytes: 0,
|
||||
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);
|
||||
filepath.set_extension("qmdl");
|
||||
if self.compressed {
|
||||
filepath.set_extension("qmdl.gz");
|
||||
} else {
|
||||
filepath.set_extension("qmdl");
|
||||
}
|
||||
filepath
|
||||
}
|
||||
|
||||
@@ -136,6 +162,89 @@ impl RecordingStore {
|
||||
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>,
|
||||
@@ -171,11 +280,19 @@ impl RecordingStore {
|
||||
}
|
||||
|
||||
// Returns the corresponding QMDL file for a given entry
|
||||
pub async fn open_entry_qmdl(&self, entry_index: usize) -> Result<File, RecordingStoreError> {
|
||||
pub async fn open_entry_qmdl(
|
||||
&self,
|
||||
entry_index: usize,
|
||||
) -> Result<QmdlReader<File>, RecordingStoreError> {
|
||||
let entry = &self.manifest.entries[entry_index];
|
||||
File::open(entry.get_qmdl_filepath(&self.path))
|
||||
let file = File::open(entry.get_qmdl_filepath(&self.path))
|
||||
.await
|
||||
.map_err(RecordingStoreError::ReadFileError)
|
||||
.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
|
||||
@@ -200,7 +317,6 @@ impl RecordingStore {
|
||||
.open(entry.get_analysis_filepath(&self.path))
|
||||
.await
|
||||
.map_err(RecordingStoreError::ReadFileError)?;
|
||||
self.update_entry_analysis_size(entry_index, 0).await?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
@@ -221,22 +337,15 @@ impl RecordingStore {
|
||||
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.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
|
||||
@@ -271,31 +380,54 @@ impl RecordingStore {
|
||||
Some((entry_index, &self.manifest.entries[entry_index]))
|
||||
}
|
||||
|
||||
pub async fn delete_entry(&mut self, name: &str) -> Result<ManifestEntry, RecordingStoreError> {
|
||||
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)?;
|
||||
if let Some(current_entry) = self.current_entry {
|
||||
if current_entry == entry_to_delete_idx {
|
||||
match self.current_entry {
|
||||
Some(current_entry) if current_entry == entry_to_delete_idx => {
|
||||
self.close_current_entry().await?;
|
||||
} else {
|
||||
}
|
||||
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);
|
||||
tokio::fs::remove_file(qmdl_filepath)
|
||||
remove_file_if_exists(&qmdl_filepath)
|
||||
.await
|
||||
.map_err(RecordingStoreError::DeleteFileError)?;
|
||||
tokio::fs::remove_file(analysis_filepath)
|
||||
remove_file_if_exists(&analysis_filepath)
|
||||
.await
|
||||
.map_err(RecordingStoreError::DeleteFileError)?;
|
||||
Ok(entry_to_delete)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_all_entries(&mut self) -> Result<(), RecordingStoreError> {
|
||||
@@ -303,22 +435,41 @@ impl RecordingStore {
|
||||
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);
|
||||
tokio::fs::remove_file(qmdl_filepath)
|
||||
.await
|
||||
.map_err(RecordingStoreError::DeleteFileError)?;
|
||||
tokio::fs::remove_file(analysis_filepath)
|
||||
.await
|
||||
.map_err(RecordingStoreError::DeleteFileError)?;
|
||||
|
||||
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);
|
||||
}
|
||||
self.manifest.entries.drain(..);
|
||||
|
||||
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::*;
|
||||
@@ -348,9 +499,11 @@ mod tests {
|
||||
RecordingStore::read_manifest(dir.path()).await.unwrap(),
|
||||
store.manifest
|
||||
);
|
||||
assert!(store.manifest.entries[entry_index]
|
||||
.last_message_time
|
||||
.is_none());
|
||||
assert!(
|
||||
store.manifest.entries[entry_index]
|
||||
.last_message_time
|
||||
.is_none()
|
||||
);
|
||||
|
||||
store
|
||||
.update_entry_qmdl_size(entry_index, 1000)
|
||||
@@ -360,7 +513,10 @@ mod tests {
|
||||
.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!(
|
||||
store.manifest.entries[entry_index].uncompressed_qmdl_size_bytes,
|
||||
1000
|
||||
);
|
||||
assert_eq!(
|
||||
RecordingStore::read_manifest(dir.path()).await.unwrap(),
|
||||
store.manifest
|
||||
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()))
|
||||
}
|
||||
3
bin/web/.gitignore → daemon/web/.gitignore
vendored
3
bin/web/.gitignore → daemon/web/.gitignore
vendored
@@ -19,6 +19,3 @@ Thumbs.db
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
@@ -2,3 +2,6 @@
|
||||
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 {};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user