mirror of
https://github.com/EFForg/rayhunter.git
synced 2026-05-30 15:49:27 -07:00
Compare commits
459 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e17842c1b9 | ||
|
|
754faf10d9 | ||
|
|
4bad8356ac | ||
|
|
7aaa7e589e | ||
|
|
e3e84a0185 | ||
|
|
517a17db14 | ||
|
|
e86d30a0c6 | ||
|
|
e83ba9922d | ||
|
|
7e525c5c02 | ||
|
|
22259dbbcc | ||
|
|
4526203af8 | ||
|
|
3e53aef145 | ||
|
|
c2ba5a2a6c | ||
|
|
5636cf4311 | ||
|
|
0c90f8910a | ||
|
|
2ada840919 | ||
|
|
19df99d663 | ||
|
|
0259b101b5 | ||
|
|
9823fd3205 | ||
|
|
0783db6e01 | ||
|
|
0033b762d9 | ||
|
|
3aa3ce89c8 | ||
|
|
bd5dfb1a75 | ||
|
|
a58bad09fc | ||
|
|
fc5593f00b | ||
|
|
049a842987 | ||
|
|
7bae34061d | ||
|
|
5a4a3034be | ||
|
|
ba78c7bd01 | ||
|
|
fee082cde4 | ||
|
|
0b91a6e5d3 | ||
|
|
dbe102e366 | ||
|
|
43d9b278cd | ||
|
|
5451e23293 | ||
|
|
66f0c2a336 | ||
|
|
adb316e2d7 | ||
|
|
c107314194 | ||
|
|
ac33ebaf53 | ||
|
|
047fbc216f | ||
|
|
d43aa28294 | ||
|
|
af09802573 | ||
|
|
866b269dbd | ||
|
|
da4e723eff | ||
|
|
a3e7d0ef43 | ||
|
|
d941021853 | ||
|
|
3fcd908d68 | ||
|
|
e7ffebbb30 | ||
|
|
6aa3491a6c | ||
|
|
f079bda4fe | ||
|
|
00e4cb7a75 | ||
|
|
c4eca245b9 | ||
|
|
768d5c877e | ||
|
|
5152544cea | ||
|
|
ba34a58c5d | ||
|
|
51da77fde0 | ||
|
|
c571bdf1de | ||
|
|
54de3b3a38 | ||
|
|
3c1a164361 | ||
|
|
8346f18a5b | ||
|
|
40070b9339 | ||
|
|
38b1dd3de2 | ||
|
|
258fdd9d21 | ||
|
|
100d1de04c | ||
|
|
177262bc1d | ||
|
|
fe69dd1abf | ||
|
|
d56386a7e5 | ||
|
|
520d2e9a9e | ||
|
|
cf9384c841 | ||
|
|
e61802fe1d | ||
|
|
1c8a498d70 | ||
|
|
7daacb3b65 | ||
|
|
4a9e9c507b | ||
|
|
a8aae16fa1 | ||
|
|
5fc6925d35 | ||
|
|
3455adbf95 | ||
|
|
416f03159a | ||
|
|
bf08066c58 | ||
|
|
d5f01847fc | ||
|
|
ca30a146b2 | ||
|
|
a1a29b5ec8 | ||
|
|
dc1d193b8e | ||
|
|
11608427bb | ||
|
|
1bab75830b | ||
|
|
853ad3763c | ||
|
|
059a5028d1 | ||
|
|
c1270cc3e9 | ||
|
|
e935904204 | ||
|
|
87975181c1 | ||
|
|
c2d8d55296 | ||
|
|
188bf812b4 | ||
|
|
ccd96de5cc | ||
|
|
a3072a2632 | ||
|
|
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 | ||
|
|
f55d9128d4 | ||
|
|
84534bbb2c | ||
|
|
1d50440c85 | ||
|
|
2c05f3d94e |
15
.cargo/audit.toml
Normal file
15
.cargo/audit.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[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",
|
||||
# rustls-webpki 0.102.8 CRL Distribution Point flaw (via rustls-rustcrypto).
|
||||
# Only affects dev builds, production firmware uses ring-tls.
|
||||
# TODO: Remove once rustls-rustcrypto releases a version newer than 0.0.2-alpha.
|
||||
"RUSTSEC-2026-0049",
|
||||
]
|
||||
@@ -1,3 +1,17 @@
|
||||
[alias]
|
||||
# Build the daemon with "firmware" profile and post-quantum TLS backend.
|
||||
# Needs an arm-linux-musleabihf cross-compiler in PATH, e.g. a toolchain
|
||||
# from https://musl.cc, or run inside messense/rust-musl-cross:armv7-musleabihf
|
||||
# (which is what CI does, see .github/workflows/main.yml).
|
||||
build-daemon-firmware = "build -p rayhunter-daemon --bin rayhunter-daemon --target armv7-unknown-linux-musleabihf --profile firmware --no-default-features --features pq-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"]
|
||||
@@ -36,6 +50,11 @@ lto = "fat"
|
||||
opt-level = "z"
|
||||
strip = "debuginfo"
|
||||
|
||||
[profile.firmware-devel]
|
||||
inherits = "release"
|
||||
opt-level = "s"
|
||||
lto = false
|
||||
|
||||
# optimizations to reduce the binary size of firmware binaries
|
||||
[profile.firmware]
|
||||
inherits = "release"
|
||||
|
||||
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
|
||||
6
.github/ISSUE_TEMPLATE/bug.yaml
vendored
6
.github/ISSUE_TEMPLATE/bug.yaml
vendored
@@ -2,6 +2,12 @@ name: Bug Report
|
||||
description: File a bug report.
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Prerequisites
|
||||
options:
|
||||
- label: I have read [CONTRIBUTING.md](https://github.com/EFForg/rayhunter/blob/main/CONTRIBUTING.md)
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Bug Report Details
|
||||
|
||||
10
.github/ISSUE_TEMPLATE/config.yml
vendored
10
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,8 +1,10 @@
|
||||
blank_issues_enabled: true
|
||||
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.
|
||||
|
||||
6
.github/ISSUE_TEMPLATE/feature.yaml
vendored
6
.github/ISSUE_TEMPLATE/feature.yaml
vendored
@@ -2,6 +2,12 @@ name: Feature Request
|
||||
description: Suggest a new feature or improvement to Rayhunter
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Prerequisites
|
||||
options:
|
||||
- label: I have read [CONTRIBUTING.md](https://github.com/EFForg/rayhunter/blob/main/CONTRIBUTING.md)
|
||||
required: true
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
|
||||
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
|
||||
53
.github/dependabot.yml
vendored
Normal file
53
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
# open-pull-requests-limit is used to disable automated version updates
|
||||
# security updates are unaffected. see
|
||||
# * https://docs.github.com/en/code-security/how-tos/secure-your-supply-chain/secure-your-dependencies/configuring-dependabot-version-updates#disabling-dependabot-version-updates
|
||||
# * https://docs.github.com/en/code-security/reference/supply-chain-security/dependabot-options-reference#open-pull-requests-limit-
|
||||
version: 2
|
||||
updates:
|
||||
# Rust dependencies
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 0
|
||||
groups:
|
||||
security:
|
||||
applies-to: "security-updates"
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
# Python dependencies
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/tools"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 0
|
||||
groups:
|
||||
security:
|
||||
applies-to: "security-updates"
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
# daemon/web Node.js dependencies
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/daemon/web"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 0
|
||||
groups:
|
||||
security:
|
||||
applies-to: "security-updates"
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
# installer-gui Node.js dependencies
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/installer-gui"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 0
|
||||
groups:
|
||||
security:
|
||||
applies-to: "security-updates"
|
||||
patterns:
|
||||
- "*"
|
||||
13
.github/pull_request_template.md
vendored
13
.github/pull_request_template.md
vendored
@@ -1,6 +1,13 @@
|
||||
## 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.
|
||||
- [ ] Your pull request is fewer than ~400 lines of code.
|
||||
|
||||
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.
|
||||
|
||||
431
.github/workflows/main.yml
vendored
431
.github/workflows/main.yml
vendored
@@ -11,6 +11,9 @@ env:
|
||||
CARGO_TERM_COLOR: always
|
||||
FILE_ROOTSHELL: ../../rootshell/rootshell
|
||||
FILE_RAYHUNTER_DAEMON: ../../rayhunter-daemon/rayhunter-daemon
|
||||
FILE_WPA_SUPPLICANT: ../../wpa-supplicant/wpa_supplicant
|
||||
FILE_WPA_CLI: ../../wpa-supplicant/wpa_cli
|
||||
FILE_IW: ../../wpa-supplicant/iw
|
||||
RUSTFLAGS: "-Dwarnings"
|
||||
|
||||
jobs:
|
||||
@@ -20,66 +23,92 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
code_changed: ${{ steps.files_changed.outputs.code_count }}
|
||||
daemon_changed: ${{ steps.files_changed.outputs.daemon_count }}
|
||||
web_changed: ${{ steps.files_changed.outputs.web_count }}
|
||||
docs_changed: ${{ steps.files_changed.outputs.docs_count }}
|
||||
installer_changed: ${{ steps.files_changed.outputs.installer_count }}
|
||||
rootshell_changed: ${{ steps.files_changed.outputs.rootshell_count }}
|
||||
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_build: ${{ steps.files_changed.outputs.installer_build != '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, or if these workflow files are being changed, run everything
|
||||
if [ ${{ github.ref }} = 'refs/heads/main' ] || git diff --name-only $lcommit..HEAD | grep -qe ^.github/workflows/ -e ^.cargo
|
||||
# We rebuild everything if any of these conditions hold:
|
||||
# * We are on main
|
||||
# * Changes are made to github workflows
|
||||
# * A cargo-workspace file changed (lockfile or .cargo), as that could affect any crate anywhere
|
||||
# * Something from the script or dist folder changed (could be gated to installer, but some scripts like build_wpa_supplicant are part of the build process)
|
||||
# * #build-all was used by the user to explicitly ask for this
|
||||
if [ ${GITHUB_REF} = 'refs/heads/main' ] || git diff --name-only $lcommit..HEAD | grep -qe ^.github/workflows/ -e ^.cargo -e '^Cargo\.lock$' -e '^Cargo\.toml$' -e ^dist/ -e ^scripts/ || 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 "installer_count=$(git diff --name-only $lcommit...HEAD | grep -e ^installer | wc -l)" >> "$GITHUB_OUTPUT"
|
||||
echo "rootshell_count=$(git diff --name-only $lcommit...HEAD | grep -e ^rootshell | wc -l)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
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 != '0'
|
||||
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_publish:
|
||||
name: Publish mdBook to Github Pages
|
||||
mdbook_build:
|
||||
name: Build mdBook for Github Pages
|
||||
needs: mdbook_test
|
||||
if: ${{ github.ref == 'refs/heads/main' }}
|
||||
permissions:
|
||||
pages: write
|
||||
contents: write
|
||||
id-token: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
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
|
||||
@@ -87,23 +116,25 @@ jobs:
|
||||
- name: Build mdBook
|
||||
run: mdbook build
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v4
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: book
|
||||
path: book
|
||||
- name: Deploy to Github Pages
|
||||
uses: actions/deploy-pages@v4
|
||||
|
||||
check_and_test:
|
||||
needs: files_changed
|
||||
if: needs.files_changed.outputs.code_changed != '0'
|
||||
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
|
||||
@@ -113,17 +144,48 @@ jobs:
|
||||
npm install
|
||||
npm run build
|
||||
popd
|
||||
NO_FIRMWARE_BIN=true cargo check --verbose
|
||||
cargo check --verbose
|
||||
- name: Run tests
|
||||
run: |
|
||||
NO_FIRMWARE_BIN=true cargo test --verbose
|
||||
cargo test --verbose
|
||||
- name: Run clippy
|
||||
run: |
|
||||
NO_FIRMWARE_BIN=true cargo clippy --verbose
|
||||
cargo clippy --verbose
|
||||
|
||||
test_web_frontend:
|
||||
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.web_changed != '0'
|
||||
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
|
||||
with:
|
||||
# Workaround for https://github.com/Swatinem/rust-cache/issues/341
|
||||
cache-bin: 'false'
|
||||
# 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: cargo check --package installer-gui --verbose
|
||||
- name: Run clippy
|
||||
run: 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
|
||||
@@ -132,33 +194,54 @@ jobs:
|
||||
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 != '0'
|
||||
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
|
||||
cargo check --verbose
|
||||
- name: cargo test
|
||||
shell: bash
|
||||
run: |
|
||||
cd installer
|
||||
NO_FIRMWARE_BIN=true cargo test --verbose --no-default-features
|
||||
cargo test --verbose --no-default-features
|
||||
|
||||
build_rayhunter_check:
|
||||
if: needs.files_changed.outputs.daemon_changed != '0'
|
||||
if: needs.files_changed.outputs.daemon_changed == 'true'
|
||||
needs:
|
||||
- check_and_test
|
||||
- files_changed
|
||||
@@ -181,7 +264,7 @@ jobs:
|
||||
os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
- name: macos-intel
|
||||
os: macos-13
|
||||
os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
- name: windows-x86_64
|
||||
os: windows-latest
|
||||
@@ -189,10 +272,15 @@ jobs:
|
||||
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
|
||||
with:
|
||||
# Workaround for https://github.com/Swatinem/rust-cache/issues/341
|
||||
cache-bin: 'false'
|
||||
- name: Build rayhunter-check
|
||||
run: cargo build --bin rayhunter-check --release --target ${{ matrix.platform.target }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
@@ -202,7 +290,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
build_rootshell:
|
||||
if: needs.files_changed.outputs.rootshell_changed != '0'
|
||||
if: needs.files_changed.outputs.rootshell_needed == 'true'
|
||||
needs:
|
||||
- check_and_test
|
||||
- files_changed
|
||||
@@ -211,20 +299,46 @@ jobs:
|
||||
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 --bin rootshell --target armv7-unknown-linux-musleabihf --profile=firmware
|
||||
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_wpa_supplicant:
|
||||
if: needs.files_changed.outputs.installer_build == 'true'
|
||||
needs:
|
||||
- files_changed
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install cross-compiler
|
||||
run: sudo apt-get update && sudo apt-get install -y gcc-arm-linux-gnueabihf
|
||||
- name: Build wpa_supplicant (armv7)
|
||||
run: CC=arm-linux-gnueabihf-gcc STRIP=arm-linux-gnueabihf-strip HOST=arm-linux-gnueabihf scripts/build-wpa-supplicant.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: wpa-supplicant
|
||||
path: |
|
||||
tools/build-wpa-supplicant/out/wpa_supplicant
|
||||
tools/build-wpa-supplicant/out/wpa_cli
|
||||
tools/build-wpa-supplicant/out/iw
|
||||
if-no-files-found: error
|
||||
|
||||
build_rayhunter:
|
||||
if: needs.files_changed.outputs.daemon_changed != '0'
|
||||
if: needs.files_changed.outputs.daemon_needed == 'true'
|
||||
needs:
|
||||
- check_and_test
|
||||
- files_changed
|
||||
@@ -234,25 +348,27 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: armv7-unknown-linux-musleabihf
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Build rayhunter-daemon (armv7)
|
||||
persist-credentials: false
|
||||
- name: Build frontend
|
||||
run: |
|
||||
pushd daemon/web
|
||||
npm install
|
||||
npm run build
|
||||
popd
|
||||
# Run with -p so that cargo will select the minimum feature set for this package.
|
||||
#
|
||||
# Otherwise, it will consider the union of all requested features
|
||||
# from all packages in the workspace. For example, if installer
|
||||
# requires tokio with "full" feature, it will be included no matter
|
||||
# what the feature selection in rayhunter-daemon is.
|
||||
#
|
||||
# https://github.com/rust-lang/cargo/issues/4463
|
||||
cargo build -p rayhunter-daemon --bin rayhunter-daemon --target armv7-unknown-linux-musleabihf --profile=firmware
|
||||
- name: Build rayhunter-daemon (armv7)
|
||||
# Cross-compile inside messense/rust-musl-cross, which bundles an
|
||||
# arm-linux-musleabihf cross gcc that aws-lc-sys needs.
|
||||
run: |
|
||||
mkdir -p "$HOME/.cargo-musl-cross"
|
||||
docker run --rm \
|
||||
--user "$(id -u):$(id -g)" \
|
||||
-v "$PWD":/work \
|
||||
-v "$HOME/.cargo-musl-cross":/cargo-home \
|
||||
-e CARGO_HOME=/cargo-home \
|
||||
-w /work \
|
||||
messense/rust-musl-cross:armv7-musleabihf \
|
||||
cargo build-daemon-firmware
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rayhunter-daemon
|
||||
@@ -260,13 +376,14 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
build_rust_installer:
|
||||
if: needs.files_changed.outputs.installer_changed != '0'
|
||||
if: needs.files_changed.outputs.installer_changed == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
needs:
|
||||
- build_rayhunter
|
||||
- build_rootshell
|
||||
- build_wpa_supplicant
|
||||
- files_changed
|
||||
- windows_installer_check_and_test
|
||||
strategy:
|
||||
@@ -285,7 +402,7 @@ jobs:
|
||||
os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
- name: macos-intel
|
||||
os: macos-13
|
||||
os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
- name: windows-x86_64
|
||||
os: windows-latest
|
||||
@@ -293,11 +410,16 @@ jobs:
|
||||
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
|
||||
with:
|
||||
# Workaround for https://github.com/Swatinem/rust-cache/issues/341
|
||||
cache-bin: 'false'
|
||||
- run: cargo build --package installer --bin installer --release --target ${{ matrix.platform.target }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -305,6 +427,152 @@ jobs:
|
||||
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
|
||||
- build_wpa_supplicant
|
||||
- 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
|
||||
- build_wpa_supplicant
|
||||
- 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
|
||||
with:
|
||||
# Workaround for https://github.com/Swatinem/rust-cache/issues/341
|
||||
# bin caching breaks cargo resolution on macOS 15 runner images
|
||||
cache-bin: 'false'
|
||||
- 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
|
||||
- build_wpa_supplicant
|
||||
- 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
|
||||
@@ -326,6 +594,8 @@ jobs:
|
||||
- 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
|
||||
@@ -335,10 +605,15 @@ jobs:
|
||||
- name: Setup versioned release directory
|
||||
run: |
|
||||
platform="${{ matrix.platform }}"
|
||||
dest="rayhunter-v${{ env.VERSION }}-${{ matrix.platform }}"
|
||||
dest="rayhunter-v${VERSION}-${{ matrix.platform }}"
|
||||
mkdir "$dest"
|
||||
mv installer-$platform/installer* "$dest"/installer
|
||||
cp -r rayhunter-daemon rootshell/rootshell dist/* installer/install.ps1 "$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"
|
||||
|
||||
@@ -350,3 +625,57 @@ jobs:
|
||||
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
|
||||
|
||||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -14,10 +14,12 @@ jobs:
|
||||
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 rootshell telcom-parser -name Cargo.toml -exec grep ^version {} \; | sort -u | wc -l)
|
||||
find lib check daemon installer rootshell telcom-parser -name Cargo.toml -exec grep ^version {} \;
|
||||
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
|
||||
@@ -41,6 +43,8 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@v4
|
||||
- name: Create release
|
||||
run: |
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
/target
|
||||
/book
|
||||
.DS_Store
|
||||
/tools/build-wpa-supplicant
|
||||
|
||||
85
CONTRIBUTING.md
Normal file
85
CONTRIBUTING.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# 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.
|
||||
|
||||
- Please keep your contributions to less than approximately 400 lines of code not counting tests, (going slightly over is fine, we aren't dogmatic about it.) This is because we are not able to give quality code review to contributions larger than that and risk introducing bugs into the system. [There was a study showing 400 LOC is the max most humans can handle.](https://smartbear.com/learn/code-review/best-practices-for-peer-code-review/)
|
||||
|
||||
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 installer-gui/src-tauri/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.
|
||||
4517
Cargo.lock
generated
4517
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
12
Cargo.toml
12
Cargo.toml
@@ -7,5 +7,17 @@ members = [
|
||||
"rootshell",
|
||||
"telcom-parser",
|
||||
"installer",
|
||||
"installer-gui/src-tauri",
|
||||
]
|
||||
# at least for now, let's keep installer-gui out of the list of default
|
||||
# packages. installer-gui is still experimental and requires many new packages
|
||||
# both from cargo and the underlying operating system
|
||||
default-members = [
|
||||
"lib",
|
||||
"daemon",
|
||||
"check",
|
||||
"rootshell",
|
||||
"telcom-parser",
|
||||
"installer",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
20
README.md
20
README.md
@@ -1,7 +1,19 @@
|
||||

|
||||
|
||||
# Rayhunter
|
||||
|
||||

|
||||
|
||||
Rayhunter is an IMSI Catcher Catcher for the Orbic mobile hotspot. To learn more, check out the [Rayhunter Book](https://efforg.github.io/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.
|
||||
|
||||
→ 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!*
|
||||
|
||||
@@ -6,3 +6,4 @@ title = "Rayhunter - An IMSI Catcher Catcher"
|
||||
|
||||
[output.html]
|
||||
edit-url-template = "https://github.com/efforg/rayhunter/edit/main/{path}"
|
||||
additional-css = ["doc/custom.css"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rayhunter-check"
|
||||
version = "0.5.0"
|
||||
version = "0.11.2"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
@@ -10,5 +10,4 @@ log = "0.4.20"
|
||||
tokio = { version = "1.44.2", default-features = false, features = ["fs", "signal", "process", "rt-multi-thread"] }
|
||||
pcap-file-tokio = "0.1.0"
|
||||
clap = { version = "4.5.2", features = ["derive"] }
|
||||
simple_logger = "5.0.0"
|
||||
walkdir = "2.5.0"
|
||||
|
||||
@@ -16,19 +16,19 @@ use walkdir::WalkDir;
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version, about)]
|
||||
struct Args {
|
||||
#[arg(short = 'p', long)]
|
||||
#[arg(short = 'p', long, help = "A file or directory of packet captures")]
|
||||
path: PathBuf,
|
||||
|
||||
#[arg(short = 'P', long)]
|
||||
#[arg(short = 'P', long, help = "Convert qmdl files to pcap before analysis")]
|
||||
pcapify: bool,
|
||||
|
||||
#[arg(long)]
|
||||
#[arg(long, help = "Show why some packets were skipped during analysis")]
|
||||
show_skipped: bool,
|
||||
|
||||
#[arg(short, long)]
|
||||
#[arg(short, long, help = "Only print warnings/errors to stdout")]
|
||||
quiet: bool,
|
||||
|
||||
#[arg(short, long)]
|
||||
#[arg(short, long, help = "Show debug messages")]
|
||||
debug: bool,
|
||||
}
|
||||
|
||||
@@ -65,10 +65,10 @@ impl Report {
|
||||
EventType::Informational => {
|
||||
info!("{}: INFO - {} {}", self.file_path, timestamp, event.message,);
|
||||
}
|
||||
EventType::QualitativeWarning { severity } => {
|
||||
EventType::Low | EventType::Medium | EventType::High => {
|
||||
warn!(
|
||||
"{}: WARNING (Severity: {:?}) - {} {}",
|
||||
self.file_path, severity, timestamp, event.message,
|
||||
self.file_path, event.event_type, timestamp, event.message,
|
||||
);
|
||||
self.warnings += 1;
|
||||
}
|
||||
@@ -155,10 +155,10 @@ async fn pcapify(qmdl_path: &PathBuf) {
|
||||
.await
|
||||
.expect("failed to get container")
|
||||
{
|
||||
for msg in container.into_messages().into_iter().flatten() {
|
||||
for msg in container.messages().into_iter().flatten() {
|
||||
if let Ok(Some((timestamp, parsed))) = gsmtap_parser::parse(msg) {
|
||||
pcap_writer
|
||||
.write_gsmtap_message(parsed, timestamp)
|
||||
.write_gsmtap_message(parsed, timestamp, None)
|
||||
.await
|
||||
.expect("failed to write");
|
||||
}
|
||||
@@ -177,14 +177,7 @@ async fn main() {
|
||||
} else {
|
||||
log::LevelFilter::Info
|
||||
};
|
||||
simple_logger::SimpleLogger::new()
|
||||
.with_colors(true)
|
||||
.without_timestamps()
|
||||
.with_level(level)
|
||||
//Filter out a stupid massive amount of uneccesary warnings from hampi about undecoded extensions
|
||||
.with_module_level("asn1_codecs", log::LevelFilter::Error)
|
||||
.init()
|
||||
.unwrap();
|
||||
rayhunter::init_logging(level);
|
||||
|
||||
let harness = Harness::new_with_config(&AnalyzerConfig::default());
|
||||
info!("Analyzers:");
|
||||
|
||||
@@ -1,26 +1,53 @@
|
||||
[package]
|
||||
name = "rayhunter-daemon"
|
||||
version = "0.5.0"
|
||||
version = "0.11.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"]
|
||||
pq-tls = ["reqwest/rustls-tls-webpki-roots-no-provider", "dep:rustls-post-quantum"]
|
||||
apidocs = ["dep:utoipa", "wifi-station/utoipa"]
|
||||
|
||||
[dependencies]
|
||||
rayhunter = { path = "../lib" }
|
||||
wifi-station = "0.10.1"
|
||||
toml = "0.8.8"
|
||||
serde = { version = "1.0.193", features = ["derive"] }
|
||||
tokio = { version = "1.44.2", default-features = false, features = ["fs", "signal", "process", "rt-multi-thread"] }
|
||||
serde_repr = "0.1"
|
||||
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"
|
||||
env_logger = { version = "0.11", default-features = false }
|
||||
tokio-util = { version = "0.7.10", features = ["rt", "io", "compat"] }
|
||||
futures-macro = "0.3.30"
|
||||
include_dir = "0.7.3"
|
||||
chrono = { version = "0.4.31", features = ["serde"] }
|
||||
tokio-stream = { version = "0.1.14", default-features = false }
|
||||
tokio-stream = { version = "0.1.14", default-features = false, features = ["io-util"] }
|
||||
futures = { version = "0.3.30", default-features = false }
|
||||
serde_json = "1.0.114"
|
||||
image = { version = "0.25.1", default-features = false, features = ["png", "gif"] }
|
||||
tempfile = "3.10.1"
|
||||
tempfile = "3.10.2"
|
||||
async_zip = { version = "0.0.17", features = ["tokio"] }
|
||||
anyhow = "1.0.98"
|
||||
reqwest = { version = "0.12.20", default-features = false, features = ["stream"] }
|
||||
rustls-rustcrypto = { version = "0.0.2-alpha", optional = true }
|
||||
rustls-post-quantum = { version = "0.2.4", optional = true }
|
||||
async-trait = "0.1.88"
|
||||
utoipa = { version = "5.4.0", optional = true }
|
||||
url = "2.5.4"
|
||||
|
||||
[dev-dependencies]
|
||||
reqwest = { version = "0.12.20", default-features = false, features = ["gzip"] }
|
||||
tempfile = "3.10.2"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
use std::{future, pin};
|
||||
use std::{cmp, future, pin};
|
||||
|
||||
use axum::Json;
|
||||
use axum::{
|
||||
@@ -8,7 +8,7 @@ use axum::{
|
||||
};
|
||||
use futures::TryStreamExt;
|
||||
use log::{error, info};
|
||||
use rayhunter::analysis::analyzer::{AnalyzerConfig, Harness};
|
||||
use rayhunter::analysis::analyzer::{AnalyzerConfig, EventType, Harness};
|
||||
use rayhunter::diag::{DataType, MessagesContainer};
|
||||
use rayhunter::qmdl::QmdlReader;
|
||||
use serde::Serialize;
|
||||
@@ -18,7 +18,7 @@ use tokio::sync::mpsc::Receiver;
|
||||
use tokio::sync::{RwLock, RwLockWriteGuard};
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
use crate::qmdl_store::RecordingStore;
|
||||
use crate::qmdl_store::{FileKind, RecordingStore};
|
||||
use crate::server::ServerState;
|
||||
|
||||
pub struct AnalysisWriter {
|
||||
@@ -47,15 +47,19 @@ impl AnalysisWriter {
|
||||
|
||||
// Runs the analysis harness on the given container, serializing the results
|
||||
// to the analysis file, returning the whether any warnings were detected
|
||||
pub async fn analyze(&mut self, container: MessagesContainer) -> Result<bool, std::io::Error> {
|
||||
let mut warning_detected = false;
|
||||
pub async fn analyze(
|
||||
&mut self,
|
||||
container: MessagesContainer,
|
||||
) -> Result<EventType, std::io::Error> {
|
||||
let mut max_type = EventType::Informational;
|
||||
|
||||
for row in self.harness.analyze_qmdl_messages(container) {
|
||||
if !row.is_empty() {
|
||||
self.write(&row).await?;
|
||||
}
|
||||
warning_detected |= row.contains_warnings();
|
||||
max_type = cmp::max(max_type, row.get_max_event_type());
|
||||
}
|
||||
Ok(warning_detected)
|
||||
Ok(max_type)
|
||||
}
|
||||
|
||||
async fn write<T: Serialize>(&mut self, value: &T) -> Result<(), std::io::Error> {
|
||||
@@ -73,10 +77,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>,
|
||||
}
|
||||
|
||||
@@ -136,9 +145,10 @@ async fn perform_analysis(
|
||||
.await
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
let qmdl_file = qmdl_store
|
||||
.open_entry_qmdl(entry_index)
|
||||
.open_file(entry_index, FileKind::Qmdl)
|
||||
.await
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
.map_err(|e| format!("{e:?}"))?
|
||||
.ok_or("QMDL file not found")?;
|
||||
|
||||
(analysis_file, qmdl_file)
|
||||
};
|
||||
@@ -211,6 +221,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)> {
|
||||
@@ -227,6 +247,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>,
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -1,22 +1,126 @@
|
||||
use log::warn;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_repr::{Deserialize_repr, Serialize_repr};
|
||||
|
||||
use rayhunter::Device;
|
||||
use rayhunter::analysis::analyzer::AnalyzerConfig;
|
||||
|
||||
use crate::error::RayhunterError;
|
||||
|
||||
#[repr(u8)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize_repr, Deserialize_repr)]
|
||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
||||
pub enum GpsMode {
|
||||
Disabled = 0,
|
||||
Fixed = 1,
|
||||
Api = 2,
|
||||
}
|
||||
|
||||
#[repr(u8)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize_repr, Deserialize_repr)]
|
||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
||||
pub enum UiLevel {
|
||||
Invisible = 0,
|
||||
Subtle = 1,
|
||||
Demo = 2,
|
||||
EffLogo = 3,
|
||||
HighVisibility = 4,
|
||||
TransFlag = 128,
|
||||
}
|
||||
|
||||
#[repr(u8)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize_repr, Deserialize_repr)]
|
||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
||||
pub enum KeyInputMode {
|
||||
Disabled = 0,
|
||||
DoubleTapPower = 1,
|
||||
}
|
||||
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,
|
||||
pub ui_level: u8,
|
||||
/// UI level
|
||||
pub ui_level: UiLevel,
|
||||
/// Colorblind mode
|
||||
pub colorblind_mode: bool,
|
||||
pub key_input_mode: u8,
|
||||
/// Key input mode
|
||||
pub key_input_mode: KeyInputMode,
|
||||
/// ntfy.sh URL
|
||||
pub ntfy_url: Option<String>,
|
||||
/// Vector containing the types of enabled notifications
|
||||
pub enabled_notifications: Vec<NotificationType>,
|
||||
/// Whether Rayhunter should periodically check GitHub for new releases
|
||||
pub auto_check_updates: bool,
|
||||
/// Vector containing the list of enabled analyzers
|
||||
pub analyzers: AnalyzerConfig,
|
||||
/// Minimum disk space required to start a recording
|
||||
pub min_space_to_start_recording_mb: u64,
|
||||
/// Minimum disk space required to continue a recording
|
||||
pub min_space_to_continue_recording_mb: u64,
|
||||
/// GPS mode
|
||||
pub gps_mode: GpsMode,
|
||||
/// Fixed latitude used when gps_mode=1
|
||||
pub gps_fixed_latitude: Option<f64>,
|
||||
/// Fixed longitude used when gps_mode=1
|
||||
pub gps_fixed_longitude: Option<f64>,
|
||||
/// Wifi client SSID
|
||||
pub wifi_ssid: Option<String>,
|
||||
/// Wifi client password
|
||||
pub wifi_password: Option<String>,
|
||||
/// Wifi security type (wpa_psk or sae)
|
||||
pub wifi_security: Option<wifi_station::SecurityType>,
|
||||
/// Wifi client mode
|
||||
pub wifi_enabled: bool,
|
||||
/// Vector containing wifi client DNS servers
|
||||
pub dns_servers: Option<Vec<String>>,
|
||||
/// WebDAV upload configuration. The upload worker runs whenever `webdav.url` is non-empty.
|
||||
pub webdav: WebdavConfig,
|
||||
}
|
||||
|
||||
/// Configuration for uploading finished QMDL recordings to a WebDAV server.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
||||
pub struct WebdavConfig {
|
||||
/// WebDAV server base URL, e.g. "https://example.com/remote.php/files/untitaker/my-subfolder/"
|
||||
pub url: String,
|
||||
/// Optional username for HTTP Basic auth
|
||||
pub username: Option<String>,
|
||||
/// Optional password for HTTP Basic auth
|
||||
pub password: Option<String>,
|
||||
/// Timeout (in seconds) for each upload request
|
||||
pub upload_timeout_secs: u64,
|
||||
/// How often (in seconds) the worker scans for entries to upload
|
||||
pub poll_interval_secs: u64,
|
||||
/// Minimum age (in seconds) an entry must have before it becomes eligible for upload
|
||||
pub min_age_secs: i64,
|
||||
/// Delete the file locally after a successful upload
|
||||
pub delete_on_upload: bool,
|
||||
}
|
||||
|
||||
impl Default for WebdavConfig {
|
||||
fn default() -> Self {
|
||||
WebdavConfig {
|
||||
url: String::new(),
|
||||
username: None,
|
||||
password: None,
|
||||
upload_timeout_secs: 300,
|
||||
poll_interval_secs: 3600,
|
||||
min_age_secs: 86400,
|
||||
delete_on_upload: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
@@ -26,24 +130,94 @@ impl Default for Config {
|
||||
port: 8080,
|
||||
debug_mode: false,
|
||||
device: Device::Orbic,
|
||||
ui_level: 1,
|
||||
ui_level: UiLevel::Subtle,
|
||||
colorblind_mode: false,
|
||||
key_input_mode: 0,
|
||||
key_input_mode: KeyInputMode::Disabled,
|
||||
analyzers: AnalyzerConfig::default(),
|
||||
ntfy_url: None,
|
||||
enabled_notifications: vec![NotificationType::Warning, NotificationType::LowBattery],
|
||||
auto_check_updates: true,
|
||||
min_space_to_start_recording_mb: 1,
|
||||
min_space_to_continue_recording_mb: 1,
|
||||
gps_mode: GpsMode::Disabled,
|
||||
gps_fixed_latitude: None,
|
||||
gps_fixed_longitude: None,
|
||||
wifi_ssid: None,
|
||||
wifi_password: None,
|
||||
wifi_security: None,
|
||||
wifi_enabled: false,
|
||||
dns_servers: None,
|
||||
webdav: WebdavConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn wifi_config(&self) -> wifi_station::WifiConfig {
|
||||
let (wpa_bin, hostapd_conf, ctrl_interface) = match self.device {
|
||||
Device::Tmobile | Device::Wingtech => (
|
||||
Some("/usr/sbin/wpa_supplicant".into()),
|
||||
Some("/data/configs/hostapd.conf".into()),
|
||||
None,
|
||||
),
|
||||
Device::Uz801 => (
|
||||
Some("/system/bin/wpa_supplicant".into()),
|
||||
Some("/data/misc/wifi/hostapd.conf".into()),
|
||||
Some("/data/misc/wifi/sockets".into()),
|
||||
),
|
||||
_ => (None, None, None),
|
||||
};
|
||||
wifi_station::WifiConfig {
|
||||
wifi_enabled: self.wifi_enabled,
|
||||
dns_servers: self.dns_servers.clone(),
|
||||
wifi_ssid: self.wifi_ssid.clone(),
|
||||
wifi_password: self.wifi_password.clone(),
|
||||
security_type: self.wifi_security,
|
||||
wpa_supplicant_bin: wpa_bin.or_else(|| resolve_bin("wpa_supplicant")),
|
||||
hostapd_conf,
|
||||
ctrl_interface,
|
||||
udhcpc_hook_path: Some("/data/rayhunter/udhcpc-hook.sh".into()),
|
||||
dhcp_lease_path: Some("/data/rayhunter/dhcp_lease".into()),
|
||||
wpa_conf_path: Some("/data/rayhunter/wpa_sta.conf".into()),
|
||||
iw_bin: resolve_bin("iw"),
|
||||
udhcpc_bin: resolve_bin("udhcpc"),
|
||||
crash_log_dir: Some("/data/rayhunter/crash-logs".into()),
|
||||
wakelock_name: Some("rayhunter".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_bin(name: &str) -> Option<String> {
|
||||
let local = format!("/data/rayhunter/bin/{name}");
|
||||
if std::path::Path::new(&local).exists() {
|
||||
return Some(local);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
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)?)
|
||||
let mut config = if let Ok(config_file) = tokio::fs::read_to_string(&path).await {
|
||||
toml::from_str(&config_file).map_err(RayhunterError::ConfigFileParsingError)?
|
||||
} else {
|
||||
warn!("unable to read config file, using default config");
|
||||
Ok(Config::default())
|
||||
Config::default()
|
||||
};
|
||||
|
||||
if let Some((ssid, security)) =
|
||||
wifi_station::read_network_from_wpa_conf("/data/rayhunter/wpa_sta.conf")
|
||||
{
|
||||
config.wifi_ssid = Some(ssid);
|
||||
config.wifi_security = Some(security);
|
||||
} else {
|
||||
config.wifi_ssid = None;
|
||||
config.wifi_security = None;
|
||||
}
|
||||
config.wifi_password = None;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub struct Args {
|
||||
|
||||
23
daemon/src/crypto_provider.rs
Normal file
23
daemon/src/crypto_provider.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use std::sync::Once;
|
||||
|
||||
static INSTALL: Once = Once::new();
|
||||
|
||||
/// Install the default rustls `CryptoProvider` for the current process.
|
||||
///
|
||||
/// This is idempotent so that it's easier to use in tests, but also panics loudly if the
|
||||
/// initialization fails.
|
||||
pub fn install_default() {
|
||||
// Crypto providers fail if they get initialized multiple times, but we don't want to just
|
||||
// ignore all errors, hence the use of once.
|
||||
INSTALL.call_once(|| {
|
||||
#[cfg(feature = "rustcrypto-tls")]
|
||||
rustls_rustcrypto::provider()
|
||||
.install_default()
|
||||
.expect("failed to install rustcrypto crypto provider");
|
||||
|
||||
#[cfg(feature = "pq-tls")]
|
||||
rustls_post_quantum::provider()
|
||||
.install_default()
|
||||
.expect("failed to install aws-lc-rs post-quantum crypto provider");
|
||||
});
|
||||
}
|
||||
@@ -1,143 +1,534 @@
|
||||
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};
|
||||
use futures::{StreamExt, TryStreamExt, future};
|
||||
use log::{debug, error, info, warn};
|
||||
use rayhunter::analysis::analyzer::AnalyzerConfig;
|
||||
use rayhunter::diag::DataType;
|
||||
use rayhunter::diag_device::DiagDevice;
|
||||
use rayhunter::qmdl::QmdlWriter;
|
||||
use rayhunter::Device;
|
||||
use tokio::fs::File;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
|
||||
use crate::gps::GpsRecord;
|
||||
use tokio::sync::mpsc::{Receiver, Sender};
|
||||
use tokio_util::io::ReaderStream;
|
||||
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, Message, MessagesContainer};
|
||||
use rayhunter::diag_device::DiagDevice;
|
||||
use rayhunter::qmdl::QmdlWriter;
|
||||
|
||||
use crate::analysis::{AnalysisCtrlMessage, AnalysisWriter};
|
||||
use crate::config::GpsMode;
|
||||
use crate::display;
|
||||
use crate::qmdl_store::{EntryType, RecordingStore, RecordingStoreError};
|
||||
use crate::notifications::{Notification, NotificationType};
|
||||
use crate::qmdl_store::{FileKind, RecordingStore, RecordingStoreError};
|
||||
use crate::server::ServerState;
|
||||
use crate::stats::DiskStats;
|
||||
|
||||
const DISK_CHECK_BYTES_INTERVAL: usize = 256 * 1024;
|
||||
|
||||
pub enum DiagDeviceCtrlMessage {
|
||||
StopRecording,
|
||||
StartRecording,
|
||||
StartRecording {
|
||||
response_tx: Option<oneshot::Sender<Result<(), RecordingStoreError>>>,
|
||||
},
|
||||
DeleteEntry {
|
||||
name: String,
|
||||
response_tx: oneshot::Sender<Result<(), RecordingStoreError>>,
|
||||
},
|
||||
DeleteAllEntries {
|
||||
response_tx: oneshot::Sender<Result<(), RecordingStoreError>>,
|
||||
},
|
||||
GpsUpdate {
|
||||
lat: f64,
|
||||
lon: f64,
|
||||
},
|
||||
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,
|
||||
gps_mode: GpsMode,
|
||||
gps_fixed_coords: Option<(f64, f64)>,
|
||||
state: DiagState,
|
||||
max_type_seen: EventType,
|
||||
bytes_since_space_check: usize,
|
||||
low_space_warned: bool,
|
||||
latest_packet_timestamp: Option<i64>,
|
||||
}
|
||||
|
||||
enum DiagState {
|
||||
Recording {
|
||||
qmdl_writer: 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 {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
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,
|
||||
gps_mode: GpsMode,
|
||||
gps_fixed_coords: Option<(f64, f64)>,
|
||||
) -> Self {
|
||||
Self {
|
||||
ui_update_sender,
|
||||
analysis_sender,
|
||||
analyzer_config,
|
||||
notification_channel,
|
||||
min_space_to_start_mb,
|
||||
min_space_to_continue_mb,
|
||||
gps_mode,
|
||||
gps_fixed_coords,
|
||||
state: DiagState::Stopped,
|
||||
max_type_seen: EventType::Informational,
|
||||
bytes_since_space_check: 0,
|
||||
low_space_warned: false,
|
||||
latest_packet_timestamp: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Start recording, returning an error if disk space is too low.
|
||||
async fn start(&mut self, qmdl_store: &mut RecordingStore) -> Result<(), RecordingStoreError> {
|
||||
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) => {
|
||||
return Err(RecordingStoreError::InsufficientDiskSpace(
|
||||
mb,
|
||||
self.min_space_to_start_mb,
|
||||
));
|
||||
}
|
||||
DiskSpaceCheck::Ok(mb) => {
|
||||
info!("Starting recording with {}MB disk space available", mb);
|
||||
}
|
||||
DiskSpaceCheck::Failed => {}
|
||||
}
|
||||
|
||||
let (qmdl_file, analysis_file) = qmdl_store.new_entry(self.gps_mode).await?;
|
||||
|
||||
// For fixed-mode sessions, write the configured coordinates to the storage
|
||||
// immediately so the per-session GPS is stored durably and isn't affected
|
||||
// by future config changes or GPS API calls.
|
||||
if self.gps_mode == GpsMode::Fixed
|
||||
&& let Some((lat, lon)) = self.gps_fixed_coords
|
||||
&& let Some((entry_idx, _)) = qmdl_store.get_current_entry()
|
||||
{
|
||||
let mut gps_file = qmdl_store
|
||||
.open_entry_gps_for_append(entry_idx)
|
||||
.await?
|
||||
.ok_or(RecordingStoreError::GpsStorageNotFound)?;
|
||||
|
||||
let record = GpsRecord {
|
||||
latest_packet_timestamp: None,
|
||||
system_time: rayhunter::clock::get_adjusted_now().timestamp(),
|
||||
lat,
|
||||
lon,
|
||||
};
|
||||
let mut json = serde_json::to_vec(&record)?;
|
||||
json.push(b'\n');
|
||||
gps_file
|
||||
.write_all(&json)
|
||||
.await
|
||||
.map_err(RecordingStoreError::WriteFileError)?;
|
||||
}
|
||||
|
||||
self.stop_current_recording().await;
|
||||
let qmdl_writer = QmdlWriter::new(qmdl_file);
|
||||
let analysis_writer = AnalysisWriter::new(analysis_file, &self.analyzer_config)
|
||||
.await
|
||||
.map_err(RecordingStoreError::WriteFileError)?;
|
||||
|
||||
self.state = DiagState::Recording {
|
||||
qmdl_writer,
|
||||
analysis_writer: Box::new(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 handle_gps_update(&mut self, qmdl_store: &RecordingStore, lat: f64, lon: f64) {
|
||||
let Some((entry_idx, _)) = qmdl_store.get_current_entry() else {
|
||||
info!("GPS update received but no recording active, not writing to storage");
|
||||
return;
|
||||
};
|
||||
let mut file = match qmdl_store.open_entry_gps_for_append(entry_idx).await {
|
||||
Ok(Some(f)) => f,
|
||||
Ok(None) => {
|
||||
error!("GPS storage not found, cannot write GPS record");
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("failed to open GPS storage: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let record = GpsRecord {
|
||||
latest_packet_timestamp: self.latest_packet_timestamp,
|
||||
system_time: rayhunter::clock::get_adjusted_now().timestamp(),
|
||||
lat,
|
||||
lon,
|
||||
};
|
||||
let Ok(mut json) = serde_json::to_vec(&record) else {
|
||||
error!("failed to serialize GPS record");
|
||||
return;
|
||||
};
|
||||
json.push(b'\n');
|
||||
if let Err(e) = file.write_all(&json).await {
|
||||
error!("failed to write GPS record to storage: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn stop_current_recording(&mut self) {
|
||||
let mut state = DiagState::Stopped;
|
||||
std::mem::swap(&mut self.state, &mut state);
|
||||
if let DiagState::Recording {
|
||||
analysis_writer, ..
|
||||
} = state
|
||||
{
|
||||
analysis_writer
|
||||
.close()
|
||||
.await
|
||||
.expect("failed to close analysis writer");
|
||||
}
|
||||
}
|
||||
|
||||
async fn process_container(
|
||||
&mut self,
|
||||
qmdl_store: &mut RecordingStore,
|
||||
container: MessagesContainer,
|
||||
) {
|
||||
if container.data_type != DataType::UserSpace {
|
||||
debug!("skipping non-userspace diag messages...");
|
||||
return;
|
||||
}
|
||||
// keep track of how many bytes were written to the QMDL file so we can read
|
||||
// a valid block of data from it in the HTTP server
|
||||
if let DiagState::Recording {
|
||||
qmdl_writer,
|
||||
analysis_writer,
|
||||
} = &mut self.state
|
||||
{
|
||||
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_written
|
||||
);
|
||||
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_written)
|
||||
.await
|
||||
{
|
||||
let reason = format!("failed to update manifest (disk full?): {e}");
|
||||
error!("{reason}");
|
||||
self.stop(qmdl_store, Some(reason)).await;
|
||||
return;
|
||||
}
|
||||
debug!("done!");
|
||||
|
||||
// Extract the latest packet timestamp from this container
|
||||
if let Some(ts) = container
|
||||
.messages()
|
||||
.into_iter()
|
||||
.filter_map(|r| match r {
|
||||
Ok(Message::Log { timestamp, .. }) => Some(timestamp.to_datetime().timestamp()),
|
||||
_ => None,
|
||||
})
|
||||
.max()
|
||||
{
|
||||
self.latest_packet_timestamp = Some(ts);
|
||||
}
|
||||
|
||||
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,
|
||||
device: Device,
|
||||
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,
|
||||
gps_mode: GpsMode,
|
||||
gps_fixed_coords: Option<(f64, f64)>,
|
||||
) {
|
||||
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));
|
||||
info!("Using configuration for device: {0:?}", device);
|
||||
let mut dev = DiagDevice::new(&device)
|
||||
.await?;
|
||||
dev.config_logs()
|
||||
.await?;
|
||||
|
||||
let mut diag_stream = pin!(dev.as_stream().into_stream());
|
||||
let mut maybe_analysis_writer = Some(AnalysisWriter::new(initial_analysis_file, &analyzer_config).await
|
||||
.expect("failed to create analysis writer"));
|
||||
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,
|
||||
gps_mode,
|
||||
gps_fixed_coords,
|
||||
);
|
||||
qmdl_file_tx
|
||||
.send(DiagDeviceCtrlMessage::StartRecording { response_tx: None })
|
||||
.await
|
||||
.unwrap();
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg = qmdl_file_rx.recv() => {
|
||||
match msg {
|
||||
Some(DiagDeviceCtrlMessage::StartRecording) => {
|
||||
Some(DiagDeviceCtrlMessage::StartRecording { response_tx }) => {
|
||||
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||
let (qmdl_file, new_analysis_file) = match qmdl_store.new_entry().await {
|
||||
Ok(x) => x,
|
||||
Err(e) => {
|
||||
error!("couldn't create new qmdl entry: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
maybe_qmdl_writer = Some(QmdlWriter::new(qmdl_file));
|
||||
|
||||
if let Some(analysis_writer) = maybe_analysis_writer {
|
||||
analysis_writer.close().await.expect("failed to close analysis writer");
|
||||
}
|
||||
|
||||
maybe_analysis_writer = Some(AnalysisWriter::new(new_analysis_file, &analyzer_config).await
|
||||
.expect("failed to write to analysis file"));
|
||||
|
||||
if let Err(e) = ui_update_sender.send(display::DisplayState::Recording).await {
|
||||
warn!("couldn't send ui update message: {e}");
|
||||
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;
|
||||
if let Some((_, entry)) = qmdl_store.get_current_entry() {
|
||||
if let Err(e) = analysis_sender
|
||||
.send(AnalysisCtrlMessage::RecordingFinished(
|
||||
entry.name.to_string(),
|
||||
))
|
||||
.await {
|
||||
warn!("couldn't send analysis message: {e}");
|
||||
}
|
||||
}
|
||||
if let Err(e) = qmdl_store.close_current_entry().await {
|
||||
error!("couldn't close current entry: {e}");
|
||||
}
|
||||
|
||||
maybe_qmdl_writer = None;
|
||||
if let Some(analysis_writer) = maybe_analysis_writer {
|
||||
analysis_writer.close().await.expect("failed to close analysis writer");
|
||||
}
|
||||
maybe_analysis_writer = None;
|
||||
|
||||
if let Err(e) = ui_update_sender.send(display::DisplayState::Paused).await {
|
||||
warn!("couldn't send ui update message: {e}");
|
||||
}
|
||||
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...");
|
||||
if let Some(analysis_writer) = maybe_analysis_writer {
|
||||
analysis_writer.close().await.expect("failed to close analysis writer");
|
||||
}
|
||||
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");
|
||||
}
|
||||
},
|
||||
Some(DiagDeviceCtrlMessage::GpsUpdate { lat, lon }) => {
|
||||
let qmdl_store = qmdl_store_lock.read().await;
|
||||
diag_task.handle_gps_update(&qmdl_store, lat, lon).await;
|
||||
},
|
||||
}
|
||||
}
|
||||
maybe_container = diag_stream.next() => {
|
||||
match maybe_container.unwrap() {
|
||||
Ok(container) => {
|
||||
if container.data_type != DataType::UserSpace {
|
||||
debug!("skipping non-userspace diag messages...");
|
||||
continue;
|
||||
}
|
||||
// keep track of how many bytes were written to the QMDL file so we can read
|
||||
// a valid block of data from it in the HTTP server
|
||||
if let Some(qmdl_writer) = maybe_qmdl_writer.as_mut() {
|
||||
qmdl_writer.write_container(&container).await.expect("failed to write to QMDL writer");
|
||||
debug!("total QMDL bytes written: {}, updating manifest...", qmdl_writer.total_written);
|
||||
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||
let index = qmdl_store.current_entry.expect("DiagDevice had qmdl_writer, but QmdlStore didn't have current entry???");
|
||||
qmdl_store.update_entry_qmdl_size(index, qmdl_writer.total_written).await
|
||||
.expect("failed to update qmdl file size");
|
||||
debug!("done!");
|
||||
} else {
|
||||
debug!("no qmdl_writer set, continuing...");
|
||||
}
|
||||
|
||||
if let Some(analysis_writer) = maybe_analysis_writer.as_mut() {
|
||||
let heuristic_warning = analysis_writer.analyze(container).await
|
||||
.expect("failed to analyze container");
|
||||
if heuristic_warning {
|
||||
info!("a heuristic triggered on this run!");
|
||||
ui_update_sender.send(display::DisplayState::WarningDetected).await
|
||||
.expect("couldn't send ui update message: {}");
|
||||
}
|
||||
}
|
||||
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}");
|
||||
@@ -150,6 +541,19 @@ pub fn run_diag_read_thread(
|
||||
});
|
||||
}
|
||||
|
||||
/// 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)> {
|
||||
@@ -157,9 +561,12 @@ pub async fn start_recording(
|
||||
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)
|
||||
.send(DiagDeviceCtrlMessage::StartRecording {
|
||||
response_tx: Some(response_tx),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
@@ -168,9 +575,29 @@ pub async fn start_recording(
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok((StatusCode::ACCEPTED, "ok".to_string()))
|
||||
match response_rx.await {
|
||||
Ok(Ok(())) => Ok((StatusCode::ACCEPTED, "ok".to_string())),
|
||||
Ok(Err(reason)) => Err((StatusCode::INSUFFICIENT_STORAGE, reason.to_string())),
|
||||
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)> {
|
||||
@@ -190,6 +617,22 @@ pub async fn stop_recording(
|
||||
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>,
|
||||
@@ -197,8 +640,27 @@ pub async fn delete_recording(
|
||||
if state.config.debug_mode {
|
||||
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
|
||||
}
|
||||
let mut qmdl_store = state.qmdl_store_lock.write().await;
|
||||
match qmdl_store.delete_entry(&qmdl_name).await {
|
||||
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}"),
|
||||
@@ -207,70 +669,67 @@ pub async fn delete_recording(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("couldn't delete recording: {e}"),
|
||||
)),
|
||||
Ok(entry_type) => {
|
||||
if entry_type == EntryType::Current {
|
||||
state
|
||||
.diag_device_ctrl_sender
|
||||
.send(DiagDeviceCtrlMessage::StopRecording)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("couldn't send stop recording message: {e}"),
|
||||
)
|
||||
})?;
|
||||
state
|
||||
.ui_update_sender
|
||||
.send(display::DisplayState::Paused)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("couldn't send ui update message: {e}"),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Ok((StatusCode::ACCEPTED, "ok".to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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::StopRecording)
|
||||
.send(DiagDeviceCtrlMessage::DeleteAllEntries { response_tx })
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("couldn't send stop recording message: {e}"),
|
||||
format!("couldn't send delete all entries message: {e}"),
|
||||
)
|
||||
})?;
|
||||
let mut qmdl_store = state.qmdl_store_lock.write().await;
|
||||
qmdl_store.delete_all_entries().await.map_err(|e| {
|
||||
match response_rx.await.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("couldn't delete all recordings: {e}"),
|
||||
format!("failed to receive delete all response: {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()))
|
||||
})? {
|
||||
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>,
|
||||
@@ -288,12 +747,21 @@ pub async fn get_analysis_report(
|
||||
))?
|
||||
};
|
||||
let analysis_file = qmdl_store
|
||||
.open_entry_analysis(entry_index)
|
||||
.open_file(entry_index, FileKind::Analysis)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?;
|
||||
let analysis_stream = ReaderStream::new(analysis_file);
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Analysis file not found".to_string()))?;
|
||||
|
||||
// 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(analysis_stream);
|
||||
let body = Body::from_stream(normalized_stream);
|
||||
Ok((headers, body).into_response())
|
||||
}
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
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::config::{self, UiLevel};
|
||||
use crate::display::DisplayState;
|
||||
use rayhunter::analysis::analyzer::EventType;
|
||||
|
||||
use log::{error, info};
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::sync::oneshot::error::TryRecvError;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
use std::thread::sleep;
|
||||
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 {
|
||||
@@ -32,6 +39,7 @@ pub enum Color {
|
||||
Cyan,
|
||||
Yellow,
|
||||
Pink,
|
||||
Orange,
|
||||
}
|
||||
|
||||
impl Color {
|
||||
@@ -45,35 +53,43 @@ impl Color {
|
||||
Color::Cyan => (0, 0xff, 0xff),
|
||||
Color::Yellow => (0xff, 0xff, 0),
|
||||
Color::Pink => (0xfe, 0x24, 0xff),
|
||||
Color::Orange => (0xff, 0xa5, 0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Color {
|
||||
fn from_state(state: DisplayState, colorblind_mode: bool) -> Self {
|
||||
match state {
|
||||
DisplayState::Paused => Color::White,
|
||||
DisplayState::Recording => {
|
||||
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
|
||||
(Color::Blue, LinePattern::Solid)
|
||||
} else {
|
||||
Color::Green
|
||||
(Color::Green, LinePattern::Solid)
|
||||
}
|
||||
}
|
||||
DisplayState::WarningDetected => Color::Red,
|
||||
}
|
||||
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;
|
||||
|
||||
fn write_buffer(
|
||||
&mut self,
|
||||
buffer: &[(u8, u8, u8)], // rgb, row-wise, left-to-right, top-to-bottom
|
||||
);
|
||||
async fn write_buffer(&mut self, buffer: Vec<(u8, u8, u8)>); // rgb, row-wise, left-to-right, top-to-bottom
|
||||
|
||||
fn write_dynamic_image(&mut self, img: DynamicImage) {
|
||||
async fn write_dynamic_image(&mut self, img: DynamicImage) {
|
||||
let dimensions = self.dimensions();
|
||||
let mut width = img.width();
|
||||
let mut height = img.height();
|
||||
@@ -86,7 +102,7 @@ pub trait GenericFramebuffer: Send + 'static {
|
||||
resized_img = img;
|
||||
}
|
||||
let img_rgba8 = resized_img.as_rgba8().unwrap();
|
||||
let mut buf = Vec::new();
|
||||
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);
|
||||
@@ -94,36 +110,60 @@ pub trait GenericFramebuffer: Send + 'static {
|
||||
}
|
||||
}
|
||||
|
||||
self.write_buffer(&buf);
|
||||
self.write_buffer(buf).await
|
||||
}
|
||||
|
||||
fn draw_gif(&mut self, img_buffer: &[u8]) {
|
||||
// this is dumb and i'm sure there's a better way to loop this
|
||||
async fn draw_gif(&mut self, img_buffer: &[u8]) {
|
||||
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));
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_img(&mut self, img_buffer: &[u8]) {
|
||||
async fn draw_img(&mut self, img_buffer: &[u8]) {
|
||||
let img = image::load_from_memory(img_buffer).unwrap();
|
||||
self.write_dynamic_image(img);
|
||||
self.write_dynamic_image(img).await
|
||||
}
|
||||
|
||||
fn draw_line(&mut self, color: Color, height: u32) {
|
||||
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 px_num = height * width;
|
||||
let mut buffer = Vec::new();
|
||||
for _ in 0..px_num {
|
||||
buffer.push(color.rgb());
|
||||
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);
|
||||
self.write_buffer(buffer).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,29 +171,30 @@ pub fn update_ui(
|
||||
task_tracker: &TaskTracker,
|
||||
config: &config::Config,
|
||||
mut fb: impl GenericFramebuffer,
|
||||
mut ui_shutdown_rx: oneshot::Receiver<()>,
|
||||
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 {
|
||||
if display_level == UiLevel::Invisible {
|
||||
info!("Invisible mode, not spawning UI.");
|
||||
return;
|
||||
}
|
||||
|
||||
let colorblind_mode = config.colorblind_mode;
|
||||
let mut display_color = Color::from_state(DisplayState::Recording, colorblind_mode);
|
||||
let mut display_style = display_style_from_state(DisplayState::Recording, colorblind_mode);
|
||||
|
||||
task_tracker.spawn_blocking(move || {
|
||||
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 {
|
||||
if display_level == UiLevel::Demo {
|
||||
img = Some(
|
||||
IMAGE_DIR
|
||||
.get_file("orca.gif")
|
||||
.expect("failed to read orca.gif")
|
||||
.contents(),
|
||||
);
|
||||
} else if display_level == 3 {
|
||||
} else if display_level == UiLevel::EffLogo {
|
||||
img = Some(
|
||||
IMAGE_DIR
|
||||
.get_file("eff.png")
|
||||
@@ -162,38 +203,39 @@ pub fn update_ui(
|
||||
);
|
||||
}
|
||||
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(state) => {
|
||||
display_color = Color::from_state(state, colorblind_mode);
|
||||
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()),
|
||||
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);
|
||||
UiLevel::Demo => fb.draw_gif(img.unwrap()).await,
|
||||
UiLevel::EffLogo => fb.draw_img(img.unwrap()).await,
|
||||
UiLevel::HighVisibility => {
|
||||
status_bar_height = fb.dimensions().height;
|
||||
}
|
||||
// this branch id for ui_level 1, which is also the default if an
|
||||
// unknown value is used
|
||||
UiLevel::TransFlag => {
|
||||
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;
|
||||
}
|
||||
// UiLevel::Subtle (1) and anything else: just the status bar line
|
||||
_ => {}
|
||||
};
|
||||
fb.draw_line(display_color, 2);
|
||||
sleep(Duration::from_millis(1000));
|
||||
let (color, pattern) = display_style;
|
||||
fb.draw_patterned_line(color, status_bar_height, pattern)
|
||||
.await;
|
||||
tokio::time::sleep(Duration::from_millis(REFRESH_RATE)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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;
|
||||
@@ -9,7 +9,7 @@ use crate::display::DisplayState;
|
||||
pub fn update_ui(
|
||||
_task_tracker: &TaskTracker,
|
||||
_config: &config::Config,
|
||||
_ui_shutdown_rx: oneshot::Receiver<()>,
|
||||
_shutdown_token: CancellationToken,
|
||||
_ui_update_rx: Receiver<DisplayState>,
|
||||
) {
|
||||
info!("Headless mode, not spawning UI.");
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
use rayhunter::analysis::analyzer::EventType;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
mod generic_framebuffer;
|
||||
|
||||
pub mod headless;
|
||||
@@ -6,11 +9,20 @@ pub mod tmobile;
|
||||
pub mod tplink;
|
||||
pub mod tplink_framebuffer;
|
||||
pub mod tplink_onebit;
|
||||
pub mod uz801;
|
||||
pub mod wingtech;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
/// 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,
|
||||
WarningDetected,
|
||||
/// 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::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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,54 +1,48 @@
|
||||
/// 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.
|
||||
/// DisplayState::WarningDetected { .. } => Signal LED slowly blinks red.
|
||||
use log::{error, info};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
use std::fs::write;
|
||||
use std::thread::sleep;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config;
|
||||
use crate::config::{self, UiLevel};
|
||||
use crate::display::DisplayState;
|
||||
|
||||
macro_rules! led {
|
||||
($l:expr) => {{ format!("/sys/class/leds/led:{}/blink", $l) }};
|
||||
}
|
||||
|
||||
fn start_blinking(path: String) {
|
||||
write(&path, "1").ok();
|
||||
async fn start_blinking(path: String) {
|
||||
tokio::fs::write(&path, "1").await.ok();
|
||||
}
|
||||
|
||||
fn stop_blinking(path: String) {
|
||||
write(&path, "0").ok();
|
||||
async fn stop_blinking(path: String) {
|
||||
tokio::fs::write(&path, "0").await.ok();
|
||||
}
|
||||
|
||||
pub fn update_ui(
|
||||
task_tracker: &TaskTracker,
|
||||
config: &config::Config,
|
||||
mut ui_shutdown_rx: oneshot::Receiver<()>,
|
||||
shutdown_token: CancellationToken,
|
||||
mut ui_update_rx: mpsc::Receiver<DisplayState>,
|
||||
) {
|
||||
let mut invisible: bool = false;
|
||||
if config.ui_level == 0 {
|
||||
if config.ui_level == UiLevel::Invisible {
|
||||
info!("Invisible mode, not spawning UI.");
|
||||
invisible = true;
|
||||
}
|
||||
task_tracker.spawn_blocking(move || {
|
||||
task_tracker.spawn(async move {
|
||||
let mut state = DisplayState::Recording;
|
||||
let mut last_state = DisplayState::Paused;
|
||||
|
||||
loop {
|
||||
match ui_shutdown_rx.try_recv() {
|
||||
Ok(_) => {
|
||||
info!("received UI shutdown");
|
||||
break;
|
||||
}
|
||||
Err(oneshot::error::TryRecvError::Empty) => {}
|
||||
Err(e) => panic!("error receiving shutdown message: {e}"),
|
||||
if shutdown_token.is_cancelled() {
|
||||
info!("received UI shutdown");
|
||||
break;
|
||||
}
|
||||
match ui_update_rx.try_recv() {
|
||||
Ok(new_state) => state = new_state,
|
||||
@@ -56,28 +50,28 @@ pub fn update_ui(
|
||||
Err(e) => error!("error receiving ui update message: {e}"),
|
||||
};
|
||||
if invisible || state == last_state {
|
||||
sleep(Duration::from_secs(1));
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
continue;
|
||||
}
|
||||
match state {
|
||||
DisplayState::Paused => {
|
||||
stop_blinking(led!("signal_blue"));
|
||||
stop_blinking(led!("signal_red"));
|
||||
start_blinking(led!("wlan_white"));
|
||||
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"));
|
||||
stop_blinking(led!("signal_red"));
|
||||
start_blinking(led!("signal_blue"));
|
||||
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"));
|
||||
stop_blinking(led!("signal_blue"));
|
||||
start_blinking(led!("signal_red"));
|
||||
DisplayState::WarningDetected { .. } => {
|
||||
stop_blinking(led!("wlan_white")).await;
|
||||
stop_blinking(led!("signal_blue")).await;
|
||||
start_blinking(led!("signal_red")).await;
|
||||
}
|
||||
}
|
||||
last_state = state;
|
||||
sleep(Duration::from_secs(1));
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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::config::{self, UiLevel};
|
||||
use crate::display::{DisplayState, tplink_framebuffer, tplink_onebit};
|
||||
|
||||
use std::fs;
|
||||
@@ -11,19 +11,21 @@ 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;
|
||||
if display_level == 0 {
|
||||
if display_level == UiLevel::Invisible {
|
||||
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::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(),
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
/// Display module for the TP-Link M7350 oled one-bit display.
|
||||
///
|
||||
/// https://github.com/m0veax/tplink_m7350/tree/main/oled
|
||||
use crate::config;
|
||||
use crate::config::{self, UiLevel};
|
||||
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,31 +111,27 @@ 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;
|
||||
if display_level == 0 {
|
||||
if display_level == UiLevel::Invisible {
|
||||
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 != UiLevel::Invisible
|
||||
&& 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::{self, UiLevel};
|
||||
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 == UiLevel::Invisible {
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
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 crate::config;
|
||||
use crate::display::DisplayState;
|
||||
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
|
||||
use async_trait::async_trait;
|
||||
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
const FB_PATH: &str = "/dev/fb0";
|
||||
@@ -17,6 +18,7 @@ const FB_PATH: &str = "/dev/fb0";
|
||||
#[derive(Copy, Clone, Default)]
|
||||
struct Framebuffer;
|
||||
|
||||
#[async_trait]
|
||||
impl GenericFramebuffer for Framebuffer {
|
||||
fn dimensions(&self) -> Dimensions {
|
||||
Dimensions {
|
||||
@@ -25,30 +27,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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use rayhunter::diag_device::DiagDeviceError;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::qmdl_store::RecordingStoreError;
|
||||
@@ -7,12 +6,16 @@ use crate::qmdl_store::RecordingStoreError;
|
||||
pub enum RayhunterError {
|
||||
#[error("Config file parsing error: {0}")]
|
||||
ConfigFileParsingError(#[from] toml::de::Error),
|
||||
#[error("Diag intialization error: {0}")]
|
||||
DiagInitError(DiagDeviceError),
|
||||
#[error("Tokio error: {0}")]
|
||||
TokioError(#[from] tokio::io::Error),
|
||||
#[error("QmdlStore error: {0}")]
|
||||
QmdlStoreError(#[from] RecordingStoreError),
|
||||
#[error("No QMDL store found at path {0}, but can't create a new one due to 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,
|
||||
}
|
||||
|
||||
151
daemon/src/gps.rs
Normal file
151
daemon/src/gps.rs
Normal file
@@ -0,0 +1,151 @@
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use log::{error, warn};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
|
||||
use crate::config::GpsMode;
|
||||
use crate::diag::DiagDeviceCtrlMessage;
|
||||
use crate::server::ServerState;
|
||||
|
||||
fn deserialize_latitude<'de, D>(deserializer: D) -> Result<f64, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
use serde::de;
|
||||
let v = f64::deserialize(deserializer)?;
|
||||
if !(-90.0..=90.0).contains(&v) {
|
||||
return Err(de::Error::custom(format!(
|
||||
"latitude {v} out of range [-90, 90]"
|
||||
)));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn deserialize_longitude<'de, D>(deserializer: D) -> Result<f64, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
use serde::de;
|
||||
let v = f64::deserialize(deserializer)?;
|
||||
if !(-180.0..=180.0).contains(&v) {
|
||||
return Err(de::Error::custom(format!(
|
||||
"longitude {v} out of range [-180, 180]"
|
||||
)));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
||||
pub struct GpsData {
|
||||
#[serde(deserialize_with = "deserialize_latitude")]
|
||||
pub latitude: f64,
|
||||
#[serde(deserialize_with = "deserialize_longitude")]
|
||||
pub longitude: f64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct GpsRecord {
|
||||
/// Packet timestamp (modem clock) for correlation with captured packets.
|
||||
/// None if no packets have been received yet.
|
||||
pub latest_packet_timestamp: Option<i64>,
|
||||
/// Drift-corrected system time when this GPS fix was received
|
||||
pub system_time: i64,
|
||||
pub lat: f64,
|
||||
pub lon: f64,
|
||||
}
|
||||
|
||||
/// Reads all GPS records from a storage NDJSON file, logging and skipping malformed lines.
|
||||
pub async fn load_gps_records(file: tokio::fs::File) -> Vec<GpsRecord> {
|
||||
let reader = BufReader::new(file);
|
||||
let mut lines = reader.lines();
|
||||
let mut records = Vec::new();
|
||||
loop {
|
||||
match lines.next_line().await {
|
||||
Ok(Some(line)) => match serde_json::from_str::<GpsRecord>(&line) {
|
||||
Ok(record) => records.push(record),
|
||||
Err(e) => warn!("skipping malformed GPS storage line: {e}"),
|
||||
},
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
error!("error reading GPS storage file: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
records.sort_by_key(|r| r.latest_packet_timestamp.unwrap_or(i64::MIN));
|
||||
records
|
||||
}
|
||||
|
||||
/// Submit GPS coordinates
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
post,
|
||||
path = "/api/gps",
|
||||
tag = "Configuration",
|
||||
request_body = GpsData,
|
||||
responses(
|
||||
(status = StatusCode::OK, description = "GPS data accepted"),
|
||||
(status = StatusCode::FORBIDDEN, description = "GPS API endpoint is disabled"),
|
||||
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Failed to send GPS update")
|
||||
),
|
||||
summary = "Submit GPS coordinates",
|
||||
description = "Submit GPS coordinates from an external source (e.g. a phone app). Requires gps_mode to be set to 'Api' in configuration. latitude is in decimal degrees from -90 to 90, longitude is in decimal degrees from -180 to 180. The timestamp is derived from the most recent packet's modem timestamp."
|
||||
))]
|
||||
pub async fn post_gps(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
Json(gps_data): Json<GpsData>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
if state.config.gps_mode != GpsMode::Api {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"GPS API endpoint is disabled. Set gps_mode to API endpoint in configuration."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Update in-memory state for GET /api/gps
|
||||
let mut gps = state.gps_state.write().await;
|
||||
*gps = Some(gps_data.clone());
|
||||
drop(gps);
|
||||
|
||||
// Send to DiagTask to write to storage with packet timestamp
|
||||
state
|
||||
.diag_device_ctrl_sender
|
||||
.send(DiagDeviceCtrlMessage::GpsUpdate {
|
||||
lat: gps_data.latitude,
|
||||
lon: gps_data.longitude,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("failed to send GPS update: {e}");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("failed to send GPS update: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
/// Get the current GPS coordinates
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
get,
|
||||
path = "/api/gps",
|
||||
tag = "Configuration",
|
||||
responses(
|
||||
(status = StatusCode::OK, description = "Current GPS data", body = GpsData),
|
||||
(status = StatusCode::NOT_FOUND, description = "No GPS data available")
|
||||
),
|
||||
summary = "Get current GPS coordinates",
|
||||
description = "Returns the most recently submitted GPS coordinates. Returns 404 if no coordinates have been submitted yet this session."
|
||||
))]
|
||||
pub async fn get_gps(State(state): State<Arc<ServerState>>) -> Result<Json<GpsData>, StatusCode> {
|
||||
let gps = state.gps_state.read().await;
|
||||
match gps.as_ref() {
|
||||
Some(data) => Ok(Json(data.clone())),
|
||||
None => Err(StatusCode::NOT_FOUND),
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,10 @@ use std::time::{Duration, Instant};
|
||||
use tokio::fs::File;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::sync::mpsc::Sender;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
use crate::config;
|
||||
use crate::config::{self, KeyInputMode};
|
||||
use crate::diag::DiagDeviceCtrlMessage;
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -21,9 +21,9 @@ pub fn run_key_input_thread(
|
||||
task_tracker: &TaskTracker,
|
||||
config: &config::Config,
|
||||
diag_tx: Sender<DiagDeviceCtrlMessage>,
|
||||
mut ui_shutdown_rx: oneshot::Receiver<()>,
|
||||
cancellation_token: CancellationToken,
|
||||
) {
|
||||
if config.key_input_mode == 0 {
|
||||
if config.key_input_mode == KeyInputMode::Disabled {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ pub fn run_key_input_thread(
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = &mut ui_shutdown_rx => {
|
||||
_ = cancellation_token.cancelled() => {
|
||||
info!("received key input shutdown");
|
||||
return;
|
||||
}
|
||||
@@ -61,11 +61,11 @@ pub fn run_key_input_thread(
|
||||
|
||||
// On orbic it was observed that pressing the power button can trigger many successive
|
||||
// events. Drop events that are too close together.
|
||||
if let Some(last_time) = last_event_time {
|
||||
if now.duration_since(last_time) < Duration::from_millis(50) {
|
||||
last_event_time = Some(now);
|
||||
continue;
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -81,8 +81,9 @@ pub fn run_key_input_thread(
|
||||
{
|
||||
error!("Failed to send StopRecording: {e}");
|
||||
}
|
||||
if let Err(e) =
|
||||
diag_tx.send(DiagDeviceCtrlMessage::StartRecording).await
|
||||
if let Err(e) = diag_tx
|
||||
.send(DiagDeviceCtrlMessage::StartRecording { response_tx: None })
|
||||
.await
|
||||
{
|
||||
error!("Failed to send StartRecording: {e}");
|
||||
}
|
||||
|
||||
78
daemon/src/lib.rs
Normal file
78
daemon/src/lib.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
pub mod analysis;
|
||||
pub mod battery;
|
||||
pub mod config;
|
||||
pub mod crypto_provider;
|
||||
pub mod diag;
|
||||
pub mod display;
|
||||
pub mod error;
|
||||
pub mod gps;
|
||||
pub mod key_input;
|
||||
pub mod notifications;
|
||||
pub mod pcap;
|
||||
pub mod qmdl_store;
|
||||
pub mod server;
|
||||
pub mod stats;
|
||||
pub mod update;
|
||||
pub mod webdav;
|
||||
|
||||
#[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_update_status,
|
||||
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,
|
||||
gps::post_gps,
|
||||
gps::get_gps
|
||||
),
|
||||
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,25 +1,39 @@
|
||||
mod analysis;
|
||||
mod battery;
|
||||
mod config;
|
||||
mod crypto_provider;
|
||||
mod diag;
|
||||
mod display;
|
||||
mod error;
|
||||
mod gps;
|
||||
mod key_input;
|
||||
mod notifications;
|
||||
mod pcap;
|
||||
mod qmdl_store;
|
||||
mod server;
|
||||
mod stats;
|
||||
mod update;
|
||||
mod webdav;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use crate::config::{parse_args, parse_config};
|
||||
use crate::battery::run_battery_notification_worker;
|
||||
use crate::config::{GpsMode, parse_args, parse_config};
|
||||
use crate::diag::run_diag_read_thread;
|
||||
use crate::error::RayhunterError;
|
||||
use crate::gps::{get_gps, post_gps};
|
||||
use crate::notifications::{NotificationService, run_notification_worker};
|
||||
use crate::pcap::get_pcap;
|
||||
use crate::qmdl_store::RecordingStore;
|
||||
use crate::server::{ServerState, get_config, get_qmdl, get_zip, serve_static, set_config};
|
||||
use crate::stats::{get_qmdl_manifest, get_system_stats};
|
||||
use crate::server::{
|
||||
ServerState, debug_set_display_state, get_config, get_qmdl, get_time, get_wifi_status, get_zip,
|
||||
scan_wifi, serve_static, set_config, set_time_offset, test_notification,
|
||||
};
|
||||
use crate::stats::{get_qmdl_manifest, get_system_stats, get_update_status};
|
||||
use crate::update::{UpdateStatus, run_update_check_worker};
|
||||
use crate::webdav::run_webdav_upload_worker;
|
||||
use wifi_station::WifiStatus;
|
||||
|
||||
use analysis::{
|
||||
AnalysisCtrlMessage, AnalysisStatus, get_analysis_status, run_analysis_thread, start_analysis,
|
||||
@@ -31,15 +45,16 @@ use diag::{
|
||||
DiagDeviceCtrlMessage, delete_all_recordings, delete_recording, get_analysis_report,
|
||||
start_recording, stop_recording,
|
||||
};
|
||||
use log::{error, info};
|
||||
use log::{error, info, warn};
|
||||
use qmdl_store::RecordingStoreError;
|
||||
use rayhunter::Device;
|
||||
use rayhunter::diag_device::DiagDevice;
|
||||
use stats::get_log;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::select;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::sync::mpsc::{self, Sender};
|
||||
use tokio::sync::{RwLock, oneshot};
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
type AppRouter = Router<Arc<ServerState>>;
|
||||
@@ -50,7 +65,9 @@ fn get_router() -> AppRouter {
|
||||
.route("/api/qmdl/{name}", get(get_qmdl))
|
||||
.route("/api/zip/{name}", get(get_zip))
|
||||
.route("/api/system-stats", get(get_system_stats))
|
||||
.route("/api/update-status", get(get_update_status))
|
||||
.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))
|
||||
@@ -60,6 +77,14 @@ fn get_router() -> AppRouter {
|
||||
.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/wifi-status", get(get_wifi_status))
|
||||
.route("/api/wifi-scan", post(scan_wifi))
|
||||
.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("/api/gps", get(get_gps))
|
||||
.route("/api/gps", post(post_gps))
|
||||
.route("/", get(|| async { Redirect::permanent("/index.html") }))
|
||||
.route("/{*path}", get(serve_static))
|
||||
}
|
||||
@@ -70,7 +95,7 @@ fn get_router() -> AppRouter {
|
||||
async fn run_server(
|
||||
task_tracker: &TaskTracker,
|
||||
state: Arc<ServerState>,
|
||||
server_shutdown_rx: oneshot::Receiver<()>,
|
||||
shutdown_token: CancellationToken,
|
||||
) -> JoinHandle<()> {
|
||||
info!("spinning up server");
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], state.config.port));
|
||||
@@ -80,17 +105,12 @@ async fn run_server(
|
||||
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 the manifest from the existing QMDL files
|
||||
@@ -122,15 +142,10 @@ 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
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn run_shutdown_thread(
|
||||
task_tracker: &TaskTracker,
|
||||
diag_device_sender: Sender<DiagDeviceCtrlMessage>,
|
||||
daemon_restart_rx: oneshot::Receiver<()>,
|
||||
should_restart_flag: Arc<AtomicBool>,
|
||||
server_shutdown_tx: oneshot::Sender<()>,
|
||||
maybe_ui_shutdown_tx: Option<oneshot::Sender<()>>,
|
||||
maybe_key_input_shutdown_tx: Option<oneshot::Sender<()>>,
|
||||
shutdown_token: CancellationToken,
|
||||
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
|
||||
analysis_tx: Sender<AnalysisCtrlMessage>,
|
||||
) -> JoinHandle<Result<(), RayhunterError>> {
|
||||
@@ -142,17 +157,9 @@ fn run_shutdown_thread(
|
||||
if let Err(err) = res {
|
||||
error!("Unable to listen for shutdown signal: {err}");
|
||||
}
|
||||
|
||||
should_restart_flag.store(false, Ordering::Relaxed);
|
||||
}
|
||||
res = daemon_restart_rx => {
|
||||
if let Err(err) = res {
|
||||
error!("Unable to listen for shutdown signal: {err}");
|
||||
}
|
||||
|
||||
should_restart_flag.store(true, Ordering::Relaxed);
|
||||
}
|
||||
};
|
||||
_ = shutdown_token.cancelled() => {}
|
||||
}
|
||||
|
||||
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||
if qmdl_store.current_entry.is_some() {
|
||||
@@ -161,15 +168,7 @@ fn run_shutdown_thread(
|
||||
info!("Done!");
|
||||
}
|
||||
|
||||
server_shutdown_tx
|
||||
.send(())
|
||||
.expect("couldn't send server shutdown signal");
|
||||
if let Some(ui_shutdown_tx) = maybe_ui_shutdown_tx {
|
||||
let _ = ui_shutdown_tx.send(());
|
||||
}
|
||||
if let Some(key_input_shutdown_tx) = maybe_key_input_shutdown_tx {
|
||||
let _ = key_input_shutdown_tx.send(());
|
||||
}
|
||||
shutdown_token.cancel();
|
||||
diag_device_sender
|
||||
.send(DiagDeviceCtrlMessage::Exit)
|
||||
.await
|
||||
@@ -182,9 +181,11 @@ fn run_shutdown_thread(
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> Result<(), RayhunterError> {
|
||||
env_logger::init();
|
||||
rayhunter::init_logging(log::LevelFilter::Info);
|
||||
|
||||
crate::crypto_provider::install_default();
|
||||
|
||||
let args = parse_args();
|
||||
|
||||
@@ -211,53 +212,68 @@ async fn run_with_config(
|
||||
let (diag_tx, diag_rx) = mpsc::channel::<DiagDeviceCtrlMessage>(1);
|
||||
let (ui_update_tx, ui_update_rx) = mpsc::channel::<display::DisplayState>(1);
|
||||
let (analysis_tx, analysis_rx) = mpsc::channel::<AnalysisCtrlMessage>(5);
|
||||
let mut maybe_ui_shutdown_tx = None;
|
||||
let mut maybe_key_input_shutdown_tx = None;
|
||||
if !config.debug_mode {
|
||||
let (ui_shutdown_tx, ui_shutdown_rx) = oneshot::channel();
|
||||
maybe_ui_shutdown_tx = Some(ui_shutdown_tx);
|
||||
info!("Using configuration for device: {0:?}", config.device);
|
||||
let mut dev = DiagDevice::new(&config.device)
|
||||
.await
|
||||
.map_err(RayhunterError::DiagInitError)?;
|
||||
dev.config_logs()
|
||||
.await
|
||||
.map_err(RayhunterError::DiagInitError)?;
|
||||
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());
|
||||
let update_status_lock = Arc::new(RwLock::new(UpdateStatus::default()));
|
||||
|
||||
if !config.debug_mode {
|
||||
info!("Starting Diag Thread");
|
||||
let gps_fixed_coords = match (config.gps_fixed_latitude, config.gps_fixed_longitude) {
|
||||
(Some(lat), Some(lon)) => Some((lat, lon)),
|
||||
_ => None,
|
||||
};
|
||||
run_diag_read_thread(
|
||||
&task_tracker,
|
||||
dev,
|
||||
config.device.clone(),
|
||||
diag_rx,
|
||||
diag_tx.clone(),
|
||||
ui_update_tx.clone(),
|
||||
qmdl_store_lock.clone(),
|
||||
analysis_tx.clone(),
|
||||
config.analyzers.clone(),
|
||||
notification_service.new_handler(),
|
||||
config.min_space_to_start_recording_mb,
|
||||
config.min_space_to_continue_recording_mb,
|
||||
config.gps_mode,
|
||||
gps_fixed_coords,
|
||||
);
|
||||
info!("Starting UI");
|
||||
|
||||
let update_ui = match &config.device {
|
||||
Device::Orbic => display::orbic::update_ui,
|
||||
Device::Orbic | Device::Moxee => display::orbic::update_ui,
|
||||
Device::Tplink => display::tplink::update_ui,
|
||||
Device::Tmobile => display::tmobile::update_ui,
|
||||
Device::Wingtech => display::wingtech::update_ui,
|
||||
Device::Pinephone => display::headless::update_ui,
|
||||
Device::Uz801 => display::uz801::update_ui,
|
||||
};
|
||||
update_ui(&task_tracker, &config, ui_shutdown_rx, ui_update_rx);
|
||||
update_ui(&task_tracker, &config, shutdown_token.clone(), ui_update_rx);
|
||||
|
||||
info!("Starting Key Input service");
|
||||
let (key_input_shutdown_tx, key_input_shutdown_rx) = oneshot::channel();
|
||||
maybe_key_input_shutdown_tx = Some(key_input_shutdown_tx);
|
||||
key_input::run_key_input_thread(
|
||||
&task_tracker,
|
||||
&config,
|
||||
diag_tx.clone(),
|
||||
key_input_shutdown_rx,
|
||||
shutdown_token.clone(),
|
||||
);
|
||||
|
||||
if config.auto_check_updates {
|
||||
run_update_check_worker(
|
||||
&task_tracker,
|
||||
shutdown_token.clone(),
|
||||
update_status_lock.clone(),
|
||||
notification_service.new_handler(),
|
||||
config.enabled_notifications.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let (daemon_restart_tx, daemon_restart_rx) = oneshot::channel::<()>();
|
||||
let (server_shutdown_tx, server_shutdown_rx) = oneshot::channel::<()>();
|
||||
let analysis_status_lock = Arc::new(RwLock::new(analysis_status));
|
||||
run_analysis_thread(
|
||||
&task_tracker,
|
||||
@@ -266,36 +282,86 @@ async fn run_with_config(
|
||||
analysis_status_lock.clone(),
|
||||
config.analyzers.clone(),
|
||||
);
|
||||
let should_restart_flag = Arc::new(AtomicBool::new(false));
|
||||
|
||||
run_shutdown_thread(
|
||||
&task_tracker,
|
||||
diag_tx.clone(),
|
||||
daemon_restart_rx,
|
||||
should_restart_flag.clone(),
|
||||
server_shutdown_tx,
|
||||
maybe_ui_shutdown_tx,
|
||||
maybe_key_input_shutdown_tx,
|
||||
shutdown_token.clone(),
|
||||
qmdl_store_lock.clone(),
|
||||
analysis_tx.clone(),
|
||||
);
|
||||
|
||||
if !config.debug_mode {
|
||||
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 wifi_status = Arc::new(RwLock::new(WifiStatus::default()));
|
||||
if !config.debug_mode {
|
||||
wifi_station::run_wifi_client(
|
||||
&task_tracker,
|
||||
&config.wifi_config(),
|
||||
shutdown_token.clone(),
|
||||
wifi_status.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
if !config.webdav.url.trim().is_empty() {
|
||||
run_webdav_upload_worker(
|
||||
&task_tracker,
|
||||
shutdown_token.clone(),
|
||||
qmdl_store_lock.clone(),
|
||||
config.webdav.clone().into(),
|
||||
);
|
||||
}
|
||||
let initial_gps = if config.gps_mode == GpsMode::Fixed {
|
||||
match (config.gps_fixed_latitude, config.gps_fixed_longitude) {
|
||||
(Some(lat), Some(lon)) => Some(gps::GpsData {
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
}),
|
||||
_ => {
|
||||
warn!(
|
||||
"gps_mode is Fixed but gps_fixed_latitude or gps_fixed_longitude is missing from config — no GPS coordinates will be recorded"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let state = Arc::new(ServerState {
|
||||
config_path: args.config_path.clone(),
|
||||
config,
|
||||
qmdl_store_lock: qmdl_store_lock.clone(),
|
||||
diag_device_ctrl_sender: diag_tx,
|
||||
ui_update_sender: ui_update_tx,
|
||||
analysis_status_lock,
|
||||
analysis_sender: analysis_tx,
|
||||
daemon_restart_tx: Arc::new(RwLock::new(Some(daemon_restart_tx))),
|
||||
daemon_restart_token: restart_token.clone(),
|
||||
ui_update_sender: Some(ui_update_tx),
|
||||
wifi_status,
|
||||
wifi_scan_lock: tokio::sync::Mutex::new(()),
|
||||
gps_state: Arc::new(tokio::sync::RwLock::new(initial_gps)),
|
||||
update_status_lock: update_status_lock.clone(),
|
||||
});
|
||||
run_server(&task_tracker, 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(should_restart_flag.load(Ordering::Relaxed))
|
||||
Ok(restart_token.is_cancelled())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
442
daemon/src/notifications.rs
Normal file
442
daemon/src/notifications.rs
Normal file
@@ -0,0 +1,442 @@
|
||||
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;
|
||||
|
||||
pub const DEFAULT_NOTIFICATION_TIMEOUT: u64 = 10; //seconds
|
||||
|
||||
#[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,
|
||||
Update,
|
||||
}
|
||||
|
||||
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>,
|
||||
timeout: u64,
|
||||
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,
|
||||
timeout: DEFAULT_NOTIFICATION_TIMEOUT,
|
||||
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,
|
||||
timeout: u64,
|
||||
) -> Result<(), NotificationError> {
|
||||
let response = http_client
|
||||
.post(url)
|
||||
.body(message)
|
||||
.timeout(Duration::from_secs(timeout))
|
||||
.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(),
|
||||
notification_service.timeout,
|
||||
)
|
||||
.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) {
|
||||
crate::crypto_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 setup_timeout_server(timeout: u64) -> String {
|
||||
crate::crypto_provider::install_default();
|
||||
|
||||
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 {
|
||||
// Accept the connection but don't respond in the timeout
|
||||
let (_socket, _addr) = listener.accept().await.unwrap();
|
||||
tokio::time::sleep(Duration::from_secs(timeout * 2)).await;
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
url
|
||||
}
|
||||
|
||||
async fn cleanup_worker(sender: mpsc::Sender<Notification>, tracker: TaskTracker) {
|
||||
drop(sender);
|
||||
tracker.close();
|
||||
tracker.wait().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send_notification_times_out() {
|
||||
let timeout: u64 = 2;
|
||||
let url = setup_timeout_server(timeout).await;
|
||||
|
||||
let http_client = reqwest::Client::new();
|
||||
let result = send_notification(
|
||||
&http_client,
|
||||
&url,
|
||||
"test warning message".to_string(),
|
||||
timeout,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Err(NotificationError::RequestFailed(reqwest_error)) => {
|
||||
println!("error = {:?}", reqwest_error);
|
||||
assert!(reqwest_error.is_timeout());
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
use crate::ServerState;
|
||||
use crate::gps::{GpsRecord, load_gps_records};
|
||||
use crate::qmdl_store::FileKind;
|
||||
use crate::server::ServerState;
|
||||
|
||||
use crate::config::GpsMode;
|
||||
use anyhow::Error;
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Path, State};
|
||||
@@ -9,15 +12,27 @@ use axum::response::{IntoResponse, Response};
|
||||
use log::error;
|
||||
use rayhunter::diag::DataType;
|
||||
use rayhunter::gsmtap_parser;
|
||||
use rayhunter::pcap::GsmtapPcapWriter;
|
||||
use rayhunter::pcap::{GpsPoint, 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>,
|
||||
@@ -38,15 +53,16 @@ pub async fn get_pcap(
|
||||
}
|
||||
let qmdl_size_bytes = entry.qmdl_size_bytes;
|
||||
let qmdl_file = qmdl_store
|
||||
.open_entry_qmdl(entry_index)
|
||||
.open_file(entry_index, FileKind::Qmdl)
|
||||
.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)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "QMDL file not found".to_string()))?;
|
||||
let (reader, writer) = duplex(1024);
|
||||
let gps_records = load_gps_records_for_entry(&state, entry_index).await;
|
||||
drop(qmdl_store);
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = generate_pcap_data(writer, qmdl_file, qmdl_size_bytes).await {
|
||||
if let Err(e) = generate_pcap_data(writer, qmdl_file, qmdl_size_bytes, gps_records).await {
|
||||
error!("failed to generate PCAP: {e:?}");
|
||||
}
|
||||
});
|
||||
@@ -56,10 +72,68 @@ pub async fn get_pcap(
|
||||
Ok((headers, body).into_response())
|
||||
}
|
||||
|
||||
pub(crate) async fn load_gps_records_for_entry(
|
||||
state: &Arc<ServerState>,
|
||||
entry_index: usize,
|
||||
) -> Vec<GpsRecord> {
|
||||
let qmdl_store = state.qmdl_store_lock.read().await;
|
||||
match qmdl_store.open_file(entry_index, FileKind::Gps).await {
|
||||
Ok(Some(file)) => load_gps_records(file).await,
|
||||
Ok(None) => {
|
||||
let gps_mode = qmdl_store
|
||||
.manifest
|
||||
.entries
|
||||
.get(entry_index)
|
||||
.and_then(|e| e.gps_mode);
|
||||
if gps_mode.is_some_and(|m| m != GpsMode::Disabled) {
|
||||
error!(
|
||||
"GPS storage expected for entry {entry_index} (mode: {gps_mode:?}) but not found"
|
||||
);
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
Err(e) => {
|
||||
error!("failed to open GPS storage: {e}");
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn record_timestamp(r: &GpsRecord) -> i64 {
|
||||
r.latest_packet_timestamp.unwrap_or(i64::MIN)
|
||||
}
|
||||
|
||||
fn find_nearest_gps(records: &[GpsRecord], packet_timestamp: i64) -> Option<GpsPoint> {
|
||||
if records.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let idx = records.partition_point(|r| record_timestamp(r) <= packet_timestamp);
|
||||
let record = if idx == 0 {
|
||||
&records[0]
|
||||
} else if idx >= records.len() {
|
||||
&records[records.len() - 1]
|
||||
} else {
|
||||
let (before, after) = (&records[idx - 1], &records[idx]);
|
||||
let before_delta = packet_timestamp - record_timestamp(before);
|
||||
let after_delta = record_timestamp(after) - packet_timestamp;
|
||||
if before_delta <= after_delta {
|
||||
before
|
||||
} else {
|
||||
after
|
||||
}
|
||||
};
|
||||
Some(GpsPoint {
|
||||
latitude: record.lat,
|
||||
longitude: record.lon,
|
||||
unix_ts: record_timestamp(record),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn generate_pcap_data<R, W>(
|
||||
writer: W,
|
||||
qmdl_file: R,
|
||||
qmdl_size_bytes: usize,
|
||||
gps_records: Vec<GpsRecord>,
|
||||
) -> Result<(), Error>
|
||||
where
|
||||
W: AsyncWrite + Unpin + Send,
|
||||
@@ -74,13 +148,15 @@ where
|
||||
continue;
|
||||
}
|
||||
|
||||
for maybe_msg in container.into_messages() {
|
||||
for maybe_msg in container.messages() {
|
||||
match maybe_msg {
|
||||
Ok(msg) => {
|
||||
let maybe_gsmtap_msg = gsmtap_parser::parse(msg)?;
|
||||
if let Some((timestamp, gsmtap_msg)) = maybe_gsmtap_msg {
|
||||
let packet_unix_ts = timestamp.to_datetime().timestamp();
|
||||
let gps = find_nearest_gps(&gps_records, packet_unix_ts);
|
||||
pcap_writer
|
||||
.write_gsmtap_message(gsmtap_msg, timestamp)
|
||||
.write_gsmtap_message(gsmtap_msg, timestamp, gps.as_ref())
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
@@ -91,3 +167,68 @@ where
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rec(latest_packet_timestamp: i64, lat: f64, lon: f64) -> GpsRecord {
|
||||
GpsRecord {
|
||||
latest_packet_timestamp: Some(latest_packet_timestamp),
|
||||
system_time: 0,
|
||||
lat,
|
||||
lon,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_returns_none() {
|
||||
assert!(find_nearest_gps(&[], 100).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_record_always_returned() {
|
||||
let records = vec![rec(100, 1.0, 2.0)];
|
||||
assert_eq!(find_nearest_gps(&records, 0).unwrap().unix_ts, 100);
|
||||
assert_eq!(find_nearest_gps(&records, 200).unwrap().unix_ts, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_before_all_records_returns_first() {
|
||||
let records = vec![rec(100, 1.0, 2.0), rec(200, 3.0, 4.0)];
|
||||
assert_eq!(find_nearest_gps(&records, 50).unwrap().unix_ts, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_after_all_records_returns_last() {
|
||||
let records = vec![rec(100, 1.0, 2.0), rec(200, 3.0, 4.0)];
|
||||
assert_eq!(find_nearest_gps(&records, 300).unwrap().unix_ts, 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exact_match() {
|
||||
let records = vec![rec(100, 1.0, 2.0), rec(200, 3.0, 4.0), rec(300, 5.0, 6.0)];
|
||||
assert_eq!(find_nearest_gps(&records, 200).unwrap().unix_ts, 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_closer_to_before() {
|
||||
// packet at 130: delta to before(100)=30, delta to after(200)=70 → picks before
|
||||
let records = vec![rec(100, 1.0, 2.0), rec(200, 3.0, 4.0)];
|
||||
assert_eq!(find_nearest_gps(&records, 130).unwrap().unix_ts, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_closer_to_after() {
|
||||
// packet at 170: delta to before(100)=70, delta to after(200)=30 → picks after
|
||||
let records = vec![rec(100, 1.0, 2.0), rec(200, 3.0, 4.0)];
|
||||
assert_eq!(find_nearest_gps(&records, 170).unwrap().unix_ts, 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_equidistant_prefers_before() {
|
||||
// packet at 150: delta to before(100)=50, delta to after(200)=50 → tie, picks before
|
||||
let records = vec![rec(100, 1.0, 2.0), rec(200, 3.0, 4.0)];
|
||||
assert_eq!(find_nearest_gps(&records, 150).unwrap().unix_ts, 100);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use std::fmt::Display;
|
||||
use std::io::{self, ErrorKind};
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::{DateTime, Local};
|
||||
use crate::config::GpsMode;
|
||||
use chrono::{DateTime, Local, TimeDelta};
|
||||
use log::{info, warn};
|
||||
use rayhunter::util::RuntimeMetadata;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -22,6 +24,8 @@ pub enum RecordingStoreError {
|
||||
CreateFileError(tokio::io::Error),
|
||||
#[error("Couldn't read file: {0}")]
|
||||
ReadFileError(tokio::io::Error),
|
||||
#[error("Couldn't write file: {0}")]
|
||||
WriteFileError(tokio::io::Error),
|
||||
#[error("Couldn't delete file: {0}")]
|
||||
DeleteFileError(tokio::io::Error),
|
||||
#[error("Couldn't open directory at path: {0}")]
|
||||
@@ -32,6 +36,46 @@ pub enum RecordingStoreError {
|
||||
WriteManifestError(tokio::io::Error),
|
||||
#[error("Couldn't parse QMDL store manifest file: {0}")]
|
||||
ParseManifestError(toml::de::Error),
|
||||
#[error("Insufficient disk space: {0}MB available, {1}MB required")]
|
||||
InsufficientDiskSpace(u64, u64),
|
||||
#[error("GPS storage directory not found")]
|
||||
GpsStorageNotFound,
|
||||
#[error("Serialization error: {0}")]
|
||||
SerializationError(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FileKind {
|
||||
Qmdl,
|
||||
Analysis,
|
||||
Gps,
|
||||
}
|
||||
|
||||
impl FileKind {
|
||||
// List of all possible physical files on disk.
|
||||
pub const ALL: &'static [FileKind] = &[FileKind::Qmdl, FileKind::Analysis, FileKind::Gps];
|
||||
|
||||
pub fn get_filename(&self, entry_name: &str) -> String {
|
||||
match self {
|
||||
FileKind::Qmdl => format!("{}.qmdl", entry_name),
|
||||
FileKind::Analysis => format!("{}.ndjson", entry_name),
|
||||
FileKind::Gps => format!("{}-gps.ndjson", entry_name),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_filepath<P: AsRef<Path>>(&self, entry_name: &str, base_path: P) -> PathBuf {
|
||||
base_path.as_ref().join(self.get_filename(entry_name))
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for FileKind {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
FileKind::Qmdl => write!(f, "QMDL"),
|
||||
FileKind::Analysis => write!(f, "analysis"),
|
||||
FileKind::Gps => write!(f, "GPS"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RecordingStore {
|
||||
@@ -45,26 +89,38 @@ pub struct Manifest {
|
||||
pub entries: Vec<ManifestEntry>,
|
||||
}
|
||||
|
||||
/// The structure of an entry in the QMDL manifest table
|
||||
#[derive(Deserialize, Serialize, Clone, PartialEq, Debug)]
|
||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
||||
pub struct ManifestEntry {
|
||||
/// The name of the entry
|
||||
pub name: String,
|
||||
/// The system time when recording began
|
||||
#[cfg_attr(feature = "apidocs", schema(value_type = String))]
|
||||
pub start_time: DateTime<Local>,
|
||||
/// The system time when the last message was recorded to the file
|
||||
#[cfg_attr(feature = "apidocs", schema(value_type = String))]
|
||||
pub last_message_time: Option<DateTime<Local>>,
|
||||
/// The size of the QMDL file in bytes
|
||||
pub 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>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq)]
|
||||
pub enum EntryType {
|
||||
Current,
|
||||
Past,
|
||||
#[serde(default)]
|
||||
pub stop_reason: Option<String>,
|
||||
/// When the manifest was uploaded to a WebDAV server
|
||||
#[cfg_attr(feature = "apidocs", schema(value_type = String))]
|
||||
pub upload_time: Option<DateTime<Local>>,
|
||||
#[serde(default)]
|
||||
pub gps_mode: Option<GpsMode>,
|
||||
}
|
||||
|
||||
impl ManifestEntry {
|
||||
fn new() -> Self {
|
||||
let now = Local::now();
|
||||
fn new(gps_mode: GpsMode) -> Self {
|
||||
let now = rayhunter::clock::get_adjusted_now();
|
||||
let metadata = RuntimeMetadata::new();
|
||||
ManifestEntry {
|
||||
name: format!("{}", now.timestamp()),
|
||||
@@ -74,19 +130,14 @@ impl ManifestEntry {
|
||||
rayhunter_version: Some(metadata.rayhunter_version),
|
||||
system_os: Some(metadata.system_os),
|
||||
arch: Some(metadata.arch),
|
||||
stop_reason: None,
|
||||
upload_time: None,
|
||||
gps_mode: Some(gps_mode),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_qmdl_filepath<P: AsRef<Path>>(&self, path: P) -> PathBuf {
|
||||
let mut filepath = path.as_ref().join(&self.name);
|
||||
filepath.set_extension("qmdl");
|
||||
filepath
|
||||
}
|
||||
|
||||
pub fn get_analysis_filepath<P: AsRef<Path>>(&self, path: P) -> PathBuf {
|
||||
let mut filepath = path.as_ref().join(&self.name);
|
||||
filepath.set_extension("ndjson");
|
||||
filepath
|
||||
pub fn get_filepath<P: AsRef<Path>>(&self, file_kind: FileKind, path: P) -> PathBuf {
|
||||
file_kind.get_filepath(&self.name, path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,11 +254,14 @@ impl RecordingStore {
|
||||
rayhunter_version: None,
|
||||
system_os: None,
|
||||
arch: None,
|
||||
stop_reason: None,
|
||||
upload_time: None,
|
||||
gps_mode: None,
|
||||
});
|
||||
}
|
||||
|
||||
// sort chronologically
|
||||
manifest_entries.sort_by(|a, b| a.start_time.cmp(&b.start_time));
|
||||
manifest_entries.sort_by_key(|a| a.start_time);
|
||||
|
||||
let mut store = RecordingStore {
|
||||
path: path.as_ref().to_path_buf(),
|
||||
@@ -235,43 +289,63 @@ impl RecordingStore {
|
||||
// Closes the current entry (if needed), creates a new entry based on the
|
||||
// current time, and updates the manifest. Returns a tuple of the entry's
|
||||
// newly created QMDL file and analysis file.
|
||||
pub async fn new_entry(&mut self) -> Result<(File, File), RecordingStoreError> {
|
||||
pub async fn new_entry(
|
||||
&mut self,
|
||||
gps_mode: GpsMode,
|
||||
) -> Result<(File, File), RecordingStoreError> {
|
||||
// if we've already got an entry open, close it
|
||||
if self.current_entry.is_some() {
|
||||
self.close_current_entry().await?;
|
||||
}
|
||||
let new_entry = ManifestEntry::new();
|
||||
let qmdl_filepath = new_entry.get_qmdl_filepath(&self.path);
|
||||
let new_entry = ManifestEntry::new(gps_mode);
|
||||
let qmdl_filepath = new_entry.get_filepath(FileKind::Qmdl, &self.path);
|
||||
let qmdl_file = File::create(&qmdl_filepath)
|
||||
.await
|
||||
.map_err(RecordingStoreError::CreateFileError)?;
|
||||
let analysis_filepath = new_entry.get_analysis_filepath(&self.path);
|
||||
let analysis_filepath = new_entry.get_filepath(FileKind::Analysis, &self.path);
|
||||
let analysis_file = File::create(&analysis_filepath)
|
||||
.await
|
||||
.map_err(RecordingStoreError::CreateFileError)?;
|
||||
let gps_filepath = new_entry.get_filepath(FileKind::Gps, &self.path);
|
||||
File::create(&gps_filepath)
|
||||
.await
|
||||
.map_err(RecordingStoreError::CreateFileError)?;
|
||||
self.manifest.entries.push(new_entry);
|
||||
self.current_entry = Some(self.manifest.entries.len() - 1);
|
||||
self.write_manifest().await?;
|
||||
Ok((qmdl_file, analysis_file))
|
||||
}
|
||||
|
||||
// Returns the corresponding QMDL file for a given entry
|
||||
pub async fn open_entry_qmdl(&self, entry_index: usize) -> Result<File, RecordingStoreError> {
|
||||
let entry = &self.manifest.entries[entry_index];
|
||||
File::open(entry.get_qmdl_filepath(&self.path))
|
||||
.await
|
||||
.map_err(RecordingStoreError::ReadFileError)
|
||||
}
|
||||
|
||||
// Returns the corresponding QMDL file for a given entry
|
||||
pub async fn open_entry_analysis(
|
||||
pub async fn open_file(
|
||||
&self,
|
||||
entry_index: usize,
|
||||
) -> Result<File, RecordingStoreError> {
|
||||
file_kind: FileKind,
|
||||
) -> Result<Option<File>, RecordingStoreError> {
|
||||
let entry = &self.manifest.entries[entry_index];
|
||||
File::open(entry.get_analysis_filepath(&self.path))
|
||||
let filepath = file_kind.get_filepath(&entry.name, &self.path);
|
||||
|
||||
match File::open(&filepath).await {
|
||||
Ok(file) => Ok(Some(file)),
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(RecordingStoreError::ReadFileError(e)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn open_entry_gps_for_append(
|
||||
&self,
|
||||
entry_index: usize,
|
||||
) -> Result<Option<File>, RecordingStoreError> {
|
||||
let entry = &self.manifest.entries[entry_index];
|
||||
match OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(entry.get_filepath(FileKind::Gps, &self.path))
|
||||
.await
|
||||
.map_err(RecordingStoreError::ReadFileError)
|
||||
{
|
||||
Ok(file) => Ok(Some(file)),
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(RecordingStoreError::CreateFileError(e)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn clear_and_open_entry_analysis(
|
||||
@@ -282,7 +356,7 @@ impl RecordingStore {
|
||||
let file = OpenOptions::new()
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(entry.get_analysis_filepath(&self.path))
|
||||
.open(entry.get_filepath(FileKind::Analysis, &self.path))
|
||||
.await
|
||||
.map_err(RecordingStoreError::ReadFileError)?;
|
||||
Ok(file)
|
||||
@@ -306,7 +380,8 @@ impl RecordingStore {
|
||||
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.manifest.entries[entry_index].last_message_time =
|
||||
Some(rayhunter::clock::get_adjusted_now());
|
||||
self.write_manifest().await
|
||||
}
|
||||
|
||||
@@ -332,6 +407,23 @@ impl RecordingStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_next_unuploaded_entry(&self, min_age: TimeDelta) -> Option<String> {
|
||||
let now = rayhunter::clock::get_adjusted_now();
|
||||
self.manifest
|
||||
.entries
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
if self.is_current_entry(&entry.name) || entry.upload_time.is_some() {
|
||||
return None;
|
||||
}
|
||||
let age = now - entry.last_message_time.unwrap_or(entry.start_time);
|
||||
|
||||
(age > min_age).then_some((&entry.name, age))
|
||||
})
|
||||
.max_by_key(|(_, age)| *age)
|
||||
.map(|(name, _)| name.clone())
|
||||
}
|
||||
|
||||
// Finds an entry by filename
|
||||
pub fn entry_for_name(&self, name: &str) -> Option<(usize, &ManifestEntry)> {
|
||||
let entry_index = self
|
||||
@@ -347,35 +439,69 @@ impl RecordingStore {
|
||||
Some((entry_index, &self.manifest.entries[entry_index]))
|
||||
}
|
||||
|
||||
pub async fn delete_entry(&mut self, name: &str) -> Result<EntryType, 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 async fn mark_entry_as_uploaded(
|
||||
&mut self,
|
||||
name: &str,
|
||||
upload_time: DateTime<Local>,
|
||||
) -> Result<(), RecordingStoreError> {
|
||||
let entry_index = self
|
||||
.manifest
|
||||
.entries
|
||||
.iter()
|
||||
.position(|entry| entry.name == name)
|
||||
.ok_or(RecordingStoreError::NoSuchEntryError)?;
|
||||
self.manifest.entries[entry_index].upload_time = Some(upload_time);
|
||||
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)?;
|
||||
let is_current = match self.current_entry {
|
||||
match self.current_entry {
|
||||
Some(current_entry) if current_entry == entry_to_delete_idx => {
|
||||
self.close_current_entry().await?;
|
||||
EntryType::Current
|
||||
}
|
||||
Some(current_entry) => {
|
||||
self.current_entry = Some(current_entry - 1);
|
||||
EntryType::Past
|
||||
}
|
||||
None => EntryType::Past,
|
||||
None => {}
|
||||
};
|
||||
let entry_to_delete = self.manifest.entries.remove(entry_to_delete_idx);
|
||||
self.write_manifest().await?;
|
||||
let qmdl_filepath = entry_to_delete.get_qmdl_filepath(&self.path);
|
||||
let analysis_filepath = entry_to_delete.get_analysis_filepath(&self.path);
|
||||
remove_file_if_exists(&qmdl_filepath)
|
||||
.await
|
||||
.map_err(RecordingStoreError::DeleteFileError)?;
|
||||
remove_file_if_exists(&analysis_filepath)
|
||||
.await
|
||||
.map_err(RecordingStoreError::DeleteFileError)?;
|
||||
Ok(is_current)
|
||||
|
||||
for &file_kind in FileKind::ALL {
|
||||
let filepath = file_kind.get_filepath(&entry_to_delete.name, &self.path);
|
||||
remove_file_if_exists(&filepath)
|
||||
.await
|
||||
.map_err(RecordingStoreError::DeleteFileError)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_all_entries(&mut self) -> Result<(), RecordingStoreError> {
|
||||
@@ -385,20 +511,16 @@ impl RecordingStore {
|
||||
|
||||
let mut keep = Vec::new();
|
||||
|
||||
for entry in &self.manifest.entries {
|
||||
let qmdl_filepath = entry.get_qmdl_filepath(&self.path);
|
||||
let analysis_filepath = entry.get_analysis_filepath(&self.path);
|
||||
|
||||
if let Err(e) = remove_file_if_exists(&qmdl_filepath).await {
|
||||
log::warn!("failed to remove {qmdl_filepath:?}: {e:?}");
|
||||
keep.push(true);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(e) = remove_file_if_exists(&analysis_filepath).await {
|
||||
log::warn!("failed to remove {analysis_filepath:?}: {e:?}");
|
||||
keep.push(true);
|
||||
continue;
|
||||
'entries: for entry in &self.manifest.entries {
|
||||
for &file_kind in FileKind::ALL {
|
||||
let filepath = file_kind.get_filepath(&entry.name, &self.path);
|
||||
if let Err(e) = remove_file_if_exists(&filepath).await {
|
||||
log::warn!("failed to remove {filepath:?}: {e:?}");
|
||||
// Some error happened with deleting this entry, abort and go to the next one.
|
||||
// Also *keep* the manifest entry.
|
||||
keep.push(true);
|
||||
continue 'entries;
|
||||
}
|
||||
}
|
||||
|
||||
keep.push(false);
|
||||
@@ -441,7 +563,7 @@ mod tests {
|
||||
async fn test_creating_updating_and_closing_entries() {
|
||||
let dir = make_temp_dir();
|
||||
let mut store = RecordingStore::create(dir.path()).await.unwrap();
|
||||
let _ = store.new_entry().await.unwrap();
|
||||
let _ = store.new_entry(GpsMode::Disabled).await.unwrap();
|
||||
let entry_index = store.current_entry.unwrap();
|
||||
assert_eq!(
|
||||
RecordingStore::read_manifest(dir.path()).await.unwrap(),
|
||||
@@ -478,7 +600,7 @@ mod tests {
|
||||
async fn test_create_on_existing_store() {
|
||||
let dir = make_temp_dir();
|
||||
let mut store = RecordingStore::create(dir.path()).await.unwrap();
|
||||
let _ = store.new_entry().await.unwrap();
|
||||
let _ = store.new_entry(GpsMode::Disabled).await.unwrap();
|
||||
let entry_index = store.current_entry.unwrap();
|
||||
store
|
||||
.update_entry_qmdl_size(entry_index, 1000)
|
||||
@@ -492,9 +614,9 @@ mod tests {
|
||||
async fn test_repeated_new_entries() {
|
||||
let dir = make_temp_dir();
|
||||
let mut store = RecordingStore::create(dir.path()).await.unwrap();
|
||||
let _ = store.new_entry().await.unwrap();
|
||||
let _ = store.new_entry(GpsMode::Disabled).await.unwrap();
|
||||
let entry_index = store.current_entry.unwrap();
|
||||
let _ = store.new_entry().await.unwrap();
|
||||
let _ = store.new_entry(GpsMode::Disabled).await.unwrap();
|
||||
let new_entry_index = store.current_entry.unwrap();
|
||||
assert_ne!(entry_index, new_entry_index);
|
||||
assert_eq!(store.manifest.entries.len(), 2);
|
||||
@@ -504,7 +626,7 @@ mod tests {
|
||||
async fn test_delete_all_entries() {
|
||||
let dir = make_temp_dir();
|
||||
let mut store = RecordingStore::create(dir.path()).await.unwrap();
|
||||
let _ = store.new_entry().await.unwrap();
|
||||
let _ = store.new_entry(GpsMode::Disabled).await.unwrap();
|
||||
assert!(store.current_entry.is_some());
|
||||
|
||||
store.delete_all_entries().await.unwrap();
|
||||
@@ -515,4 +637,78 @@ mod tests {
|
||||
store.delete_all_entries().await.unwrap();
|
||||
assert!(store.current_entry.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mark_entry_as_uploaded_sets_time_and_persists() {
|
||||
let dir = make_temp_dir();
|
||||
let mut store = RecordingStore::create(dir.path()).await.unwrap();
|
||||
let _ = store.new_entry(GpsMode::Disabled).await.unwrap();
|
||||
let name = store.manifest.entries[0].name.clone();
|
||||
store.close_current_entry().await.unwrap();
|
||||
|
||||
let upload_time = Local::now();
|
||||
store
|
||||
.mark_entry_as_uploaded(&name, upload_time)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(store.manifest.entries[0].upload_time, Some(upload_time));
|
||||
|
||||
let reloaded = RecordingStore::load(dir.path()).await.unwrap();
|
||||
assert_eq!(reloaded.manifest.entries[0].upload_time, Some(upload_time));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mark_entry_as_uploaded_missing_entry() {
|
||||
let dir = make_temp_dir();
|
||||
let mut store = RecordingStore::create(dir.path()).await.unwrap();
|
||||
assert!(matches!(
|
||||
store.mark_entry_as_uploaded("nope", Local::now()).await,
|
||||
Err(RecordingStoreError::NoSuchEntryError)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_next_unuploaded_entry() {
|
||||
let dir = make_temp_dir();
|
||||
let mut store = RecordingStore::create(dir.path()).await.unwrap();
|
||||
|
||||
for _ in 0..3 {
|
||||
let _ = store.new_entry(GpsMode::Disabled).await.unwrap();
|
||||
}
|
||||
|
||||
store.manifest.entries[0].name = "entry-0".to_owned();
|
||||
store.manifest.entries[0].start_time = Local::now() - TimeDelta::seconds(10);
|
||||
store.manifest.entries[0].last_message_time = None;
|
||||
|
||||
store.manifest.entries[1].name = "entry-1".to_owned();
|
||||
store.manifest.entries[1].start_time = Local::now() - TimeDelta::seconds(10);
|
||||
store.manifest.entries[1].last_message_time = Some(Local::now() - TimeDelta::seconds(5));
|
||||
|
||||
store.manifest.entries[2].name = "entry-2".to_owned();
|
||||
store.manifest.entries[2].start_time = Local::now() - TimeDelta::seconds(10);
|
||||
store.manifest.entries[2].last_message_time = Some(Local::now() - TimeDelta::seconds(1));
|
||||
|
||||
assert_eq!(
|
||||
store.get_next_unuploaded_entry(TimeDelta::seconds(3600)),
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
store.get_next_unuploaded_entry(TimeDelta::seconds(3)),
|
||||
Some("entry-0".to_owned())
|
||||
);
|
||||
store
|
||||
.mark_entry_as_uploaded("entry-0", Local::now())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
store.get_next_unuploaded_entry(TimeDelta::seconds(3)),
|
||||
Some("entry-1".to_owned())
|
||||
);
|
||||
store
|
||||
.mark_entry_as_uploaded("entry-1", Local::now())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(store.get_next_unuploaded_entry(TimeDelta::seconds(3)), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,32 +9,58 @@ 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::{AsyncReadExt, copy, duplex};
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::sync::mpsc::Sender;
|
||||
use tokio::sync::{RwLock, oneshot};
|
||||
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::pcap::generate_pcap_data;
|
||||
use crate::qmdl_store::RecordingStore;
|
||||
use crate::{DiagDeviceCtrlMessage, display};
|
||||
use crate::config::{Config, GpsMode};
|
||||
use crate::diag::DiagDeviceCtrlMessage;
|
||||
use crate::display::DisplayState;
|
||||
use crate::gps::GpsData;
|
||||
use crate::notifications::DEFAULT_NOTIFICATION_TIMEOUT;
|
||||
use crate::pcap::{generate_pcap_data, load_gps_records_for_entry};
|
||||
use crate::qmdl_store::{FileKind, RecordingStore};
|
||||
use crate::update::UpdateStatus;
|
||||
|
||||
pub struct ServerState {
|
||||
pub config_path: String,
|
||||
pub config: Config,
|
||||
pub qmdl_store_lock: Arc<RwLock<RecordingStore>>,
|
||||
pub diag_device_ctrl_sender: Sender<DiagDeviceCtrlMessage>,
|
||||
pub ui_update_sender: Sender<display::DisplayState>,
|
||||
pub analysis_status_lock: Arc<RwLock<AnalysisStatus>>,
|
||||
pub analysis_sender: Sender<AnalysisCtrlMessage>,
|
||||
pub daemon_restart_tx: Arc<RwLock<Option<oneshot::Sender<()>>>>,
|
||||
pub daemon_restart_token: CancellationToken,
|
||||
pub ui_update_sender: Option<Sender<DisplayState>>,
|
||||
pub wifi_status: Arc<RwLock<wifi_station::WifiStatus>>,
|
||||
pub wifi_scan_lock: tokio::sync::Mutex<()>,
|
||||
pub gps_state: Arc<RwLock<Option<GpsData>>>,
|
||||
pub update_status_lock: Arc<RwLock<UpdateStatus>>,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
@@ -46,14 +72,15 @@ pub async fn get_qmdl(
|
||||
format!("couldn't find qmdl file with name {qmdl_idx}"),
|
||||
))?;
|
||||
let qmdl_file = qmdl_store
|
||||
.open_entry_qmdl(entry_index)
|
||||
.open_file(entry_index, FileKind::Qmdl)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("error opening QMDL file: {err}"),
|
||||
)
|
||||
})?;
|
||||
})?
|
||||
.ok_or((StatusCode::NOT_FOUND, "QMDL file not found".to_string()))?;
|
||||
let limited_qmdl_file = qmdl_file.take(entry.qmdl_size_bytes as u64);
|
||||
let qmdl_stream = ReaderStream::new(limited_qmdl_file);
|
||||
|
||||
@@ -72,11 +99,6 @@ pub async fn serve_static(
|
||||
let path = path.trim_start_matches('/');
|
||||
|
||||
match path {
|
||||
"rayhunter_icon.png" => (
|
||||
[(header::CONTENT_TYPE, HeaderValue::from_static("image/png"))],
|
||||
include_bytes!("../web/build/rayhunter_icon.png"),
|
||||
)
|
||||
.into_response(),
|
||||
"rayhunter_orca_only.png" => (
|
||||
[(header::CONTENT_TYPE, HeaderValue::from_static("image/png"))],
|
||||
include_bytes!("../web/build/rayhunter_orca_only.png"),
|
||||
@@ -107,17 +129,54 @@ pub async fn serve_static(
|
||||
}
|
||||
}
|
||||
|
||||
#[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()))
|
||||
let mut config = state.config.clone();
|
||||
config.wifi_password = None;
|
||||
Ok(Json(config))
|
||||
}
|
||||
|
||||
#[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>,
|
||||
Json(mut config): Json<Config>,
|
||||
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||
let config_str = toml::to_string_pretty(&config).map_err(|err| {
|
||||
if config.gps_mode != GpsMode::Fixed {
|
||||
config.gps_fixed_latitude = None;
|
||||
config.gps_fixed_longitude = None;
|
||||
}
|
||||
let mut config_to_write = config.clone();
|
||||
config_to_write.wifi_ssid = None;
|
||||
config_to_write.wifi_password = None;
|
||||
config_to_write.wifi_security = None;
|
||||
|
||||
let config_str = toml::to_string_pretty(&config_to_write).map_err(|err| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("failed to serialize config as TOML: {err}"),
|
||||
@@ -131,27 +190,145 @@ pub async fn set_config(
|
||||
)
|
||||
})?;
|
||||
|
||||
wifi_station::update_wpa_conf(&config.wifi_config()).await;
|
||||
|
||||
// Trigger daemon restart after writing config
|
||||
let mut restart_tx = state.daemon_restart_tx.write().await;
|
||||
if let Some(sender) = restart_tx.take() {
|
||||
sender.send(()).map_err(|_| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"couldn't send restart signal".to_string(),
|
||||
)
|
||||
})?;
|
||||
Ok((
|
||||
StatusCode::ACCEPTED,
|
||||
"wrote config and triggered restart".to_string(),
|
||||
))
|
||||
} else {
|
||||
Ok((
|
||||
StatusCode::ACCEPTED,
|
||||
"wrote config but restart already triggered".to_string(),
|
||||
))
|
||||
}
|
||||
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,
|
||||
DEFAULT_NOTIFICATION_TIMEOUT,
|
||||
)
|
||||
.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>,
|
||||
@@ -175,6 +352,7 @@ pub async fn get_zip(
|
||||
};
|
||||
|
||||
let qmdl_store_lock = state.qmdl_store_lock.clone();
|
||||
let gps_records = load_gps_records_for_entry(&state, entry_index).await;
|
||||
|
||||
let (reader, writer) = duplex(8192);
|
||||
|
||||
@@ -182,24 +360,40 @@ pub async fn get_zip(
|
||||
let result: Result<(), Error> = async {
|
||||
let mut zip = ZipFileWriter::with_tokio(writer);
|
||||
|
||||
// Add QMDL file
|
||||
{
|
||||
let entry =
|
||||
ZipEntryBuilder::new(format!("{qmdl_idx}.qmdl").into(), Compression::Stored);
|
||||
const EXCLUDED_FROM_ZIP: &[FileKind] = &[FileKind::Analysis];
|
||||
|
||||
// Add stored files
|
||||
for &file_kind in FileKind::ALL {
|
||||
if EXCLUDED_FROM_ZIP.contains(&file_kind) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let file_opt = {
|
||||
let qmdl_store = qmdl_store_lock.read().await;
|
||||
qmdl_store.open_file(entry_index, file_kind).await?
|
||||
};
|
||||
|
||||
let Some(mut file) = file_opt else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let entry = ZipEntryBuilder::new(
|
||||
file_kind.get_filename(&qmdl_idx).into(),
|
||||
Compression::Stored,
|
||||
);
|
||||
// FuturesAsyncWriteCompatExt::compat_write because async-zip's entrystream does
|
||||
// not impl tokio's AsyncWrite, but only future's AsyncWrite. This can be removed
|
||||
// once https://github.com/Majored/rs-async-zip/pull/160 is released.
|
||||
let mut entry_writer = zip.write_entry_stream(entry).await?.compat_write();
|
||||
|
||||
let mut qmdl_file = {
|
||||
let qmdl_store = qmdl_store_lock.read().await;
|
||||
qmdl_store
|
||||
.open_entry_qmdl(entry_index)
|
||||
.await?
|
||||
.take(qmdl_size_bytes as u64)
|
||||
};
|
||||
// Truncating to qmdl_size_bytes is an attempt to ignore partial writes by the diag
|
||||
// thread.
|
||||
if file_kind == FileKind::Qmdl {
|
||||
copy(&mut file.take(qmdl_size_bytes as u64), &mut entry_writer).await?;
|
||||
} else {
|
||||
copy(&mut file, &mut entry_writer).await?;
|
||||
}
|
||||
|
||||
copy(&mut qmdl_file, &mut entry_writer).await?;
|
||||
entry_writer.into_inner().close().await?;
|
||||
}
|
||||
|
||||
@@ -212,13 +406,19 @@ pub async fn get_zip(
|
||||
let qmdl_file_for_pcap = {
|
||||
let qmdl_store = qmdl_store_lock.read().await;
|
||||
qmdl_store
|
||||
.open_entry_qmdl(entry_index)
|
||||
.open_file(entry_index, FileKind::Qmdl)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("QMDL file not found"))?
|
||||
.take(qmdl_size_bytes as u64)
|
||||
};
|
||||
|
||||
if let Err(e) =
|
||||
generate_pcap_data(&mut entry_writer, qmdl_file_for_pcap, qmdl_size_bytes).await
|
||||
if let Err(e) = generate_pcap_data(
|
||||
&mut entry_writer,
|
||||
qmdl_file_for_pcap,
|
||||
qmdl_size_bytes,
|
||||
gps_records,
|
||||
)
|
||||
.await
|
||||
{
|
||||
// if we fail to generate the PCAP file, we should still continue and give the
|
||||
// user the QMDL.
|
||||
@@ -243,9 +443,97 @@ pub async fn get_zip(
|
||||
Ok((headers, body).into_response())
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
get,
|
||||
path = "/api/wifi-status",
|
||||
tag = "Configuration",
|
||||
responses(
|
||||
(status = StatusCode::OK, description = "Success", body = wifi_station::WifiStatus)
|
||||
),
|
||||
summary = "Get wifi status",
|
||||
description = "Show the status of the wifi client."
|
||||
))]
|
||||
pub async fn get_wifi_status(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
) -> Json<wifi_station::WifiStatus> {
|
||||
let status = state.wifi_status.read().await;
|
||||
Json(status.clone())
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
post,
|
||||
path = "/api/wifi-scan",
|
||||
tag = "Configuration",
|
||||
responses(
|
||||
(status = StatusCode::OK, description = "Scan success", body = inline(Vec<wifi_station::WifiNetwork>), content_type = "application/json"),
|
||||
(status = StatusCode::TOO_MANY_REQUESTS, description = "Scan already in progress"),
|
||||
(status = StatusCode::INTERNAL_SERVER_ERROR, description = "Scan failed"),
|
||||
),
|
||||
summary = "Wifi SSID scan",
|
||||
description = "Poll for a list of available wifi networks. Returns an array of WifiNetwork objects."
|
||||
))]
|
||||
pub async fn scan_wifi(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
) -> Result<Json<Vec<wifi_station::WifiNetwork>>, (StatusCode, String)> {
|
||||
let _guard = state.wifi_scan_lock.try_lock().map_err(|_| {
|
||||
(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"WiFi scan already in progress".to_string(),
|
||||
)
|
||||
})?;
|
||||
let networks = wifi_station::scan_wifi_networks(wifi_station::STA_IFACE)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("WiFi scan failed: {e}"),
|
||||
)
|
||||
})?;
|
||||
Ok(Json(networks))
|
||||
}
|
||||
|
||||
#[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 crate::config::GpsMode;
|
||||
use async_zip::base::read::mem::ZipFileReader;
|
||||
use axum::extract::{Path, State};
|
||||
use tempfile::TempDir;
|
||||
@@ -265,7 +553,7 @@ mod tests {
|
||||
) -> String {
|
||||
let entry_name = {
|
||||
let mut store = store_lock.write().await;
|
||||
let (mut qmdl_file, _analysis_file) = store.new_entry().await.unwrap();
|
||||
let (mut qmdl_file, _analysis_file) = store.new_entry(GpsMode::Disabled).await.unwrap();
|
||||
|
||||
if !test_data.is_empty() {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
@@ -293,7 +581,6 @@ mod tests {
|
||||
store_lock: Arc<RwLock<crate::qmdl_store::RecordingStore>>,
|
||||
) -> Arc<ServerState> {
|
||||
let (tx, _rx) = tokio::sync::mpsc::channel(1);
|
||||
let (ui_tx, _ui_rx) = tokio::sync::mpsc::channel(1);
|
||||
let (analysis_tx, _analysis_rx) = tokio::sync::mpsc::channel(1);
|
||||
|
||||
let analysis_status = {
|
||||
@@ -306,10 +593,14 @@ mod tests {
|
||||
config: Config::default(),
|
||||
qmdl_store_lock: store_lock,
|
||||
diag_device_ctrl_sender: tx,
|
||||
ui_update_sender: ui_tx,
|
||||
analysis_status_lock: Arc::new(RwLock::new(analysis_status)),
|
||||
analysis_sender: analysis_tx,
|
||||
daemon_restart_tx: Arc::new(RwLock::new(None)),
|
||||
daemon_restart_token: CancellationToken::new(),
|
||||
ui_update_sender: None,
|
||||
wifi_status: Arc::new(RwLock::new(wifi_station::WifiStatus::default())),
|
||||
wifi_scan_lock: tokio::sync::Mutex::new(()),
|
||||
gps_state: Arc::new(RwLock::new(None)),
|
||||
update_status_lock: Arc::new(RwLock::new(UpdateStatus::default())),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -342,7 +633,11 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
filenames,
|
||||
vec![format!("{entry_name}.qmdl"), format!("{entry_name}.pcapng"),]
|
||||
vec![
|
||||
format!("{entry_name}.qmdl"),
|
||||
format!("{entry_name}-gps.ndjson"),
|
||||
format!("{entry_name}.pcapng"),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,67 +1,115 @@
|
||||
use std::ffi::CString;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::qmdl_store::ManifestEntry;
|
||||
use crate::battery::get_battery_status;
|
||||
use crate::error::RayhunterError;
|
||||
use crate::server::ServerState;
|
||||
use crate::update::UpdateStatus;
|
||||
use crate::{battery::BatteryState, qmdl_store::ManifestEntry};
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use log::error;
|
||||
use rayhunter::util::RuntimeMetadata;
|
||||
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) -> Result<Self, String> {
|
||||
pub async fn new(qmdl_path: &str, device: &Device) -> Result<Self, String> {
|
||||
Ok(Self {
|
||||
disk_stats: DiskStats::new(qmdl_path).await?,
|
||||
memory_stats: MemoryStats::new().await?,
|
||||
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 {
|
||||
// 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();
|
||||
#[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 = format!(
|
||||
"{}%",
|
||||
((stat.f_blocks - stat.f_bfree) * 100)
|
||||
.checked_div(stat.f_blocks)
|
||||
.unwrap_or(0)
|
||||
);
|
||||
|
||||
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(),
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -83,9 +131,16 @@ async fn get_cmd_output(mut cmd: Command) -> Result<String, 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");
|
||||
// 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
|
||||
@@ -107,11 +162,22 @@ fn humanize_kb(kb: usize) -> String {
|
||||
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()).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}");
|
||||
@@ -123,12 +189,26 @@ pub async fn get_system_stats(
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)> {
|
||||
@@ -140,3 +220,34 @@ pub async fn get_qmdl_manifest(
|
||||
current_entry,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||
get,
|
||||
path = "/api/update-status",
|
||||
tag = "Statistics",
|
||||
responses(
|
||||
(status = StatusCode::OK, description = "Success", body = UpdateStatus)
|
||||
),
|
||||
summary = "Rayhunter update status",
|
||||
description = "Check for available updates for Rayhunter."
|
||||
))]
|
||||
pub async fn get_update_status(State(state): State<Arc<ServerState>>) -> Json<UpdateStatus> {
|
||||
Json(state.update_status_lock.read().await.clone())
|
||||
}
|
||||
|
||||
#[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()))
|
||||
}
|
||||
|
||||
274
daemon/src/update.rs
Normal file
274
daemon/src/update.rs
Normal file
@@ -0,0 +1,274 @@
|
||||
use chrono::{DateTime, Local};
|
||||
use log::{error, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::select;
|
||||
use tokio::sync::{RwLock, mpsc::Sender};
|
||||
use tokio::time;
|
||||
use tokio::time::{Duration, MissedTickBehavior};
|
||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||
|
||||
use crate::notifications::{Notification, NotificationType};
|
||||
|
||||
const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60);
|
||||
const GITHUB_LATEST_RELEASE_URL: &str =
|
||||
"https://api.github.com/repos/EFForg/rayhunter/releases/latest";
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
||||
pub struct UpdateStatus {
|
||||
pub current_version: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub latest_version: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub latest_release_url: Option<String>,
|
||||
pub update_available: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(feature = "apidocs", schema(value_type = Option<String>, format = "date-time"))]
|
||||
pub last_checked: Option<DateTime<Local>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for UpdateStatus {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
current_version: get_current_version(),
|
||||
// To-be-populated by update check worker
|
||||
latest_version: None,
|
||||
latest_release_url: None,
|
||||
update_available: false,
|
||||
last_checked: None,
|
||||
last_error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GitHubReleaseResponse {
|
||||
tag_name: String,
|
||||
html_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd)]
|
||||
struct VersionParts {
|
||||
major: u64,
|
||||
minor: u64,
|
||||
patch: u64,
|
||||
}
|
||||
|
||||
fn get_current_version() -> String {
|
||||
// See https://doc.rust-lang.org/cargo/reference/environment-variables.html#environment-variables-cargo-sets-for-crates
|
||||
env!("CARGO_PKG_VERSION").to_owned()
|
||||
}
|
||||
|
||||
fn parse_release_tagname(version: &str) -> Option<(VersionParts, String)> {
|
||||
// Trim whitespace and leading `v`, if any
|
||||
let trimmed_version = version.trim().trim_start_matches('v');
|
||||
let mut parts = trimmed_version.split('.');
|
||||
|
||||
// Fail on versions with pre-release metadata: https://github.com/EFForg/rayhunter/pull/1054#issuecomment-4528407281
|
||||
let major = parts.next()?.parse::<u64>().ok()?;
|
||||
let minor = parts.next()?.parse::<u64>().ok()?;
|
||||
let patch = parts.next()?.parse::<u64>().ok()?;
|
||||
// Expect only major.minor.patch format
|
||||
if parts.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let version = format!("{}.{}.{}", major, minor, patch);
|
||||
Some((
|
||||
VersionParts {
|
||||
major,
|
||||
minor,
|
||||
patch,
|
||||
},
|
||||
version.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn format_update_message(current_version: &str, latest_version: &str, release_url: &str) -> String {
|
||||
format!(
|
||||
"Rayhunter {current_version} is installed, but {latest_version} is available. Open {release_url} to download the update."
|
||||
)
|
||||
}
|
||||
|
||||
async fn refresh_update_status(
|
||||
status_lock: &Arc<RwLock<UpdateStatus>>,
|
||||
http_client: &reqwest::Client,
|
||||
) -> Result<Option<(String, String)>, String> {
|
||||
let response = http_client
|
||||
.get(GITHUB_LATEST_RELEASE_URL)
|
||||
.timeout(Duration::from_secs(5))
|
||||
.header(reqwest::header::USER_AGENT, "rayhunter-update-checker")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("failed to query GitHub releases: {err}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"GitHub release check returned {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
let response_text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| format!("failed to read GitHub release response: {err}"))?;
|
||||
let release: GitHubReleaseResponse = serde_json::from_str(&response_text)
|
||||
.map_err(|err| format!("failed to parse GitHub release response: {err}"))?;
|
||||
|
||||
let current_version = get_current_version();
|
||||
let (current_version_parts, current_version) = parse_release_tagname(¤t_version)
|
||||
.ok_or_else(|| format!("failed to parse current version {current_version}"))?;
|
||||
let (latest_version_parts, latest_version) = parse_release_tagname(&release.tag_name)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"failed to parse latest release version {}",
|
||||
release.tag_name
|
||||
)
|
||||
})?;
|
||||
|
||||
let update_available = latest_version_parts > current_version_parts;
|
||||
{
|
||||
let mut status = status_lock.write().await;
|
||||
status.current_version = current_version;
|
||||
status.latest_version = Some(latest_version.to_owned());
|
||||
status.latest_release_url = Some(release.html_url.to_owned());
|
||||
status.update_available = update_available;
|
||||
status.last_checked = Some(Local::now());
|
||||
status.last_error = None;
|
||||
}
|
||||
|
||||
if update_available {
|
||||
Ok(Some((latest_version, release.html_url)))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_update_check_worker(
|
||||
task_tracker: &TaskTracker,
|
||||
shutdown_token: CancellationToken,
|
||||
update_status_lock: Arc<RwLock<UpdateStatus>>,
|
||||
notification_sender: Sender<Notification>,
|
||||
enabled_notifications: Vec<NotificationType>,
|
||||
) {
|
||||
task_tracker.spawn(async move {
|
||||
let http_client = match reqwest::Client::builder().build() {
|
||||
Ok(client) => client,
|
||||
Err(err) => {
|
||||
error!("failed to create update check client: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut interval = time::interval(UPDATE_CHECK_INTERVAL);
|
||||
interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||
|
||||
// Keep track of last notified version
|
||||
let mut last_notified_version: Option<String> = None;
|
||||
|
||||
loop {
|
||||
if shutdown_token.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
|
||||
match refresh_update_status(&update_status_lock, &http_client).await {
|
||||
Ok(Some((latest_version, latest_release_url))) => {
|
||||
if last_notified_version.as_deref() != Some(latest_version.as_str()) {
|
||||
let current_version =
|
||||
update_status_lock.read().await.current_version.clone();
|
||||
let message = format_update_message(
|
||||
¤t_version,
|
||||
&latest_version,
|
||||
&latest_release_url,
|
||||
);
|
||||
if enabled_notifications.contains(&NotificationType::Update) {
|
||||
if let Err(err) = notification_sender
|
||||
.send(Notification::new(NotificationType::Update, message, None))
|
||||
.await
|
||||
{
|
||||
error!("failed to enqueue update notification: {err}");
|
||||
} else {
|
||||
info!("notified about Rayhunter update {latest_version}");
|
||||
}
|
||||
}
|
||||
last_notified_version = Some(latest_version);
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
last_notified_version = None;
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("update check failed: {err}");
|
||||
let mut status = update_status_lock.write().await;
|
||||
status.last_error = Some(err);
|
||||
status.last_checked = Some(Local::now());
|
||||
}
|
||||
}
|
||||
|
||||
select! {
|
||||
_ = shutdown_token.cancelled() => break,
|
||||
_ = interval.tick() => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_release_tagname;
|
||||
|
||||
#[test]
|
||||
fn parses_simple_versions() {
|
||||
let (parts, version) = parse_release_tagname("0.11.1").unwrap();
|
||||
assert_eq!(parts.major, 0);
|
||||
assert_eq!(parts.minor, 11);
|
||||
assert_eq!(parts.patch, 1);
|
||||
assert_eq!(version, "0.11.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_for_invalid_versions() {
|
||||
assert!(parse_release_tagname("invalid").is_none());
|
||||
assert!(parse_release_tagname("v1.2").is_none());
|
||||
assert!(parse_release_tagname("v1.2.x").is_none());
|
||||
assert!(parse_release_tagname("v1.2.3.4").is_none());
|
||||
assert!(parse_release_tagname("v1.2.-3").is_none());
|
||||
assert!(parse_release_tagname("v1.2.3-beta").is_none());
|
||||
assert!(parse_release_tagname("v1.2.3-beta.1").is_none());
|
||||
assert!(parse_release_tagname("1.2").is_none());
|
||||
assert!(parse_release_tagname("1.2.x").is_none());
|
||||
assert!(parse_release_tagname("1.2.3.4").is_none());
|
||||
assert!(parse_release_tagname("1.2.-3").is_none());
|
||||
assert!(parse_release_tagname("1.2.3-beta").is_none());
|
||||
assert!(parse_release_tagname("1.2.3-beta.1").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compares_versions_numerically() {
|
||||
let (newer_version_parts, newer_version) = parse_release_tagname("v0.11.2").unwrap();
|
||||
let (older_version_parts, older_version) = parse_release_tagname("v0.11.1").unwrap();
|
||||
assert!(newer_version_parts > older_version_parts);
|
||||
assert_eq!(newer_version, "0.11.2");
|
||||
assert_eq!(older_version, "0.11.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compares_major_minor_patch_correctly() {
|
||||
let (v1_parts, v1) = parse_release_tagname("v1.0.0").unwrap();
|
||||
let (v2_parts, v2) = parse_release_tagname("v1.0.1").unwrap();
|
||||
let (v3_parts, v3) = parse_release_tagname("v1.1.0").unwrap();
|
||||
let (v4_parts, v4) = parse_release_tagname("v2.0.0").unwrap();
|
||||
|
||||
assert!(v2_parts > v1_parts);
|
||||
assert!(v3_parts > v2_parts);
|
||||
assert!(v4_parts > v3_parts);
|
||||
|
||||
assert_eq!(v1, "1.0.0");
|
||||
assert_eq!(v2, "1.0.1");
|
||||
assert_eq!(v3, "1.1.0");
|
||||
assert_eq!(v4, "2.0.0");
|
||||
}
|
||||
}
|
||||
424
daemon/src/webdav.rs
Normal file
424
daemon/src/webdav.rs
Normal file
@@ -0,0 +1,424 @@
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use chrono::TimeDelta;
|
||||
use futures::future::join_all;
|
||||
use log::{info, warn};
|
||||
use reqwest::header::{CONTENT_LENGTH, CONTENT_TYPE};
|
||||
use reqwest::{Body, Client, Response};
|
||||
use tokio::fs::File;
|
||||
use tokio::{select, sync::RwLock, time};
|
||||
use tokio_util::io::ReaderStream;
|
||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||
|
||||
use crate::config::WebdavConfig;
|
||||
use crate::qmdl_store::{FileKind, RecordingStore};
|
||||
|
||||
pub struct WebdavUploadWorkerConfig {
|
||||
poll_interval: Duration,
|
||||
min_age: TimeDelta,
|
||||
url: String,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
timeout: Duration,
|
||||
delete_on_upload: bool,
|
||||
}
|
||||
|
||||
impl From<WebdavConfig> for WebdavUploadWorkerConfig {
|
||||
fn from(value: WebdavConfig) -> Self {
|
||||
WebdavUploadWorkerConfig {
|
||||
poll_interval: Duration::from_secs(value.poll_interval_secs),
|
||||
min_age: TimeDelta::seconds(value.min_age_secs),
|
||||
url: value.url,
|
||||
username: value.username,
|
||||
password: value.password,
|
||||
timeout: Duration::from_secs(value.upload_timeout_secs),
|
||||
delete_on_upload: value.delete_on_upload,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct WebDavClient {
|
||||
client: Client,
|
||||
url: String,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
}
|
||||
|
||||
impl WebDavClient {
|
||||
fn new(
|
||||
mut url: String,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
timeout: Duration,
|
||||
) -> Result<Self, reqwest::Error> {
|
||||
if !url.ends_with('/') {
|
||||
url.push('/');
|
||||
}
|
||||
Ok(Self {
|
||||
client: reqwest::Client::builder().timeout(timeout).build()?,
|
||||
url,
|
||||
username,
|
||||
password,
|
||||
})
|
||||
}
|
||||
|
||||
async fn try_upload_file(&self, file: File, name: &str) -> anyhow::Result<Response> {
|
||||
let file_size = file.metadata().await?.len();
|
||||
|
||||
let stream = ReaderStream::new(file);
|
||||
let body = Body::wrap_stream(stream);
|
||||
|
||||
let target = format!("{}{}", self.url, name);
|
||||
|
||||
let client = self
|
||||
.client
|
||||
.put(&target)
|
||||
.header(CONTENT_TYPE, "application/octet-stream")
|
||||
.header(CONTENT_LENGTH, file_size);
|
||||
|
||||
let client = match (&self.username, &self.password) {
|
||||
(Some(username), Some(password)) => client.basic_auth(username, Some(password)),
|
||||
(Some(username), None) => client.basic_auth(username, None::<&str>),
|
||||
(None, None) => client,
|
||||
(None, Some(_)) => {
|
||||
warn!(
|
||||
"Got WebDAV auth setting with no username but with a password, skipping authentication"
|
||||
);
|
||||
client
|
||||
}
|
||||
};
|
||||
|
||||
let resp = client.body(body).send().await?.error_for_status();
|
||||
Ok(resp?)
|
||||
}
|
||||
}
|
||||
|
||||
async fn try_upload_entry(
|
||||
client: WebDavClient,
|
||||
store: Arc<RwLock<RecordingStore>>,
|
||||
entry_name: String,
|
||||
file_kind: FileKind,
|
||||
shutdown_token: CancellationToken,
|
||||
) -> Option<()> {
|
||||
let read_lock = store.read().await;
|
||||
let entry_idx = read_lock.entry_for_name(&entry_name)?.0;
|
||||
let file = read_lock.open_file(entry_idx, file_kind).await;
|
||||
drop(read_lock);
|
||||
|
||||
let file = match file {
|
||||
Ok(Some(f)) => f,
|
||||
Ok(None) => return Some(()), // File doesn't exist (e.g., GPS for old recordings)
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"Unable to open entry: {} {} file: {:?}",
|
||||
entry_name, file_kind, err
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let file_name = file_kind.get_filename(&entry_name);
|
||||
|
||||
let res = select! {
|
||||
_ = shutdown_token.cancelled() => {
|
||||
warn!(
|
||||
"Cancelling upload for entry {} {} file: received shutdown signal",
|
||||
entry_name, file_kind
|
||||
);
|
||||
return None;
|
||||
},
|
||||
res = client.try_upload_file(file, &file_name) => res,
|
||||
};
|
||||
|
||||
match res {
|
||||
Ok(_) => {
|
||||
info!("Uploaded {} file for entry {}", file_kind, entry_name);
|
||||
Some(())
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"Failed to upload {} file for entry {}: {:?}",
|
||||
file_kind, entry_name, err
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_webdav_upload_worker(
|
||||
task_tracker: &TaskTracker,
|
||||
shutdown_token: CancellationToken,
|
||||
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
|
||||
config: WebdavUploadWorkerConfig,
|
||||
) {
|
||||
task_tracker.spawn(async move {
|
||||
let mut interval = time::interval(config.poll_interval);
|
||||
interval.set_missed_tick_behavior(time::MissedTickBehavior::Skip);
|
||||
|
||||
let webdav_client = match WebDavClient::new(
|
||||
config.url,
|
||||
config.username,
|
||||
config.password,
|
||||
config.timeout,
|
||||
) {
|
||||
Ok(client) => client,
|
||||
Err(err) => {
|
||||
warn!("Unable to create WebDAV client: {:?}", err);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
loop {
|
||||
select! {
|
||||
_ = shutdown_token.cancelled() => break,
|
||||
_ = interval.tick() => {
|
||||
loop {
|
||||
let Some(unuploaded_entry) = qmdl_store_lock
|
||||
.read()
|
||||
.await
|
||||
.get_next_unuploaded_entry(config.min_age) else {
|
||||
break;
|
||||
};
|
||||
|
||||
let upload_futures: Vec<_> = FileKind::ALL
|
||||
.iter()
|
||||
.map(|&file_kind| {
|
||||
try_upload_entry(
|
||||
webdav_client.clone(),
|
||||
qmdl_store_lock.clone(),
|
||||
unuploaded_entry.clone(),
|
||||
file_kind,
|
||||
shutdown_token.clone(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let results = join_all(upload_futures).await;
|
||||
if !results.iter().all(|r| r.is_some()) {
|
||||
break;
|
||||
}
|
||||
|
||||
if config.delete_on_upload {
|
||||
match qmdl_store_lock.write().await.delete_entry(&unuploaded_entry).await {
|
||||
Ok(_) => info!("Successfully deleted entry: {} after upload to WebDAV", unuploaded_entry),
|
||||
Err(err) => warn!("Unable to delete entry: {} after upload to WebDAV: {}", unuploaded_entry, err),
|
||||
}
|
||||
} else {
|
||||
match qmdl_store_lock.write().await.mark_entry_as_uploaded(&unuploaded_entry, rayhunter::clock::get_adjusted_now()).await {
|
||||
Ok(_) => info!("Successfully marked entry: {} as uploaded", unuploaded_entry),
|
||||
Err(err) => warn!("Unable to mark entry: {} as uploaded: {}", unuploaded_entry, err),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::GpsMode;
|
||||
use axum::{
|
||||
Router,
|
||||
body::Bytes,
|
||||
extract::{Path as AxumPath, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
routing::put,
|
||||
};
|
||||
use tempfile::Builder;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct RecordedPut {
|
||||
path: String,
|
||||
auth: Option<String>,
|
||||
body: Vec<u8>,
|
||||
}
|
||||
|
||||
async fn capture_put(
|
||||
State(state): State<Arc<Mutex<Vec<RecordedPut>>>>,
|
||||
AxumPath(path): AxumPath<String>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> StatusCode {
|
||||
let auth = headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(String::from);
|
||||
state.lock().await.push(RecordedPut {
|
||||
path,
|
||||
auth,
|
||||
body: body.to_vec(),
|
||||
});
|
||||
StatusCode::CREATED
|
||||
}
|
||||
|
||||
async fn setup_webdav_server() -> (Arc<Mutex<Vec<RecordedPut>>>, String) {
|
||||
crate::crypto_provider::install_default();
|
||||
|
||||
let state = Arc::new(Mutex::new(Vec::new()));
|
||||
let app = Router::new()
|
||||
.route("/{*path}", put(capture_put))
|
||||
.with_state(state.clone());
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let url = format!("http://{}/dav", addr);
|
||||
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
(state, url)
|
||||
}
|
||||
|
||||
async fn cleanup_worker(shutdown: CancellationToken, tracker: TaskTracker) {
|
||||
shutdown.cancel();
|
||||
tracker.close();
|
||||
tracker.wait().await;
|
||||
}
|
||||
|
||||
async fn make_store_with_closed_entry(
|
||||
dir: &std::path::Path,
|
||||
) -> (Arc<RwLock<RecordingStore>>, String) {
|
||||
let mut store = RecordingStore::create(dir).await.unwrap();
|
||||
let (mut qmdl_file, mut analysis_file) = store.new_entry(GpsMode::Disabled).await.unwrap();
|
||||
qmdl_file.write_all(b"fake qmdl payload").await.unwrap();
|
||||
qmdl_file.flush().await.unwrap();
|
||||
analysis_file
|
||||
.write_all(b"fake ndjson payload")
|
||||
.await
|
||||
.unwrap();
|
||||
analysis_file.flush().await.unwrap();
|
||||
let entry_index = store.current_entry.unwrap();
|
||||
let name = store.manifest.entries[entry_index].name.clone();
|
||||
store.update_entry_qmdl_size(entry_index, 17).await.unwrap();
|
||||
store.close_current_entry().await.unwrap();
|
||||
(Arc::new(RwLock::new(store)), name)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_webdav_upload_worker_uploads_entry() {
|
||||
let (captured, url) = setup_webdav_server().await;
|
||||
|
||||
let dir = Builder::new().prefix("webdav_test").tempdir().unwrap();
|
||||
let (store, entry_name) = make_store_with_closed_entry(dir.path()).await;
|
||||
|
||||
let shutdown = CancellationToken::new();
|
||||
let tracker = TaskTracker::new();
|
||||
let config = WebdavUploadWorkerConfig {
|
||||
poll_interval: Duration::from_millis(50),
|
||||
min_age: TimeDelta::seconds(-1),
|
||||
url,
|
||||
username: Some("user".to_string()),
|
||||
password: Some("password".to_string()),
|
||||
timeout: Duration::from_secs(1),
|
||||
delete_on_upload: false,
|
||||
};
|
||||
|
||||
run_webdav_upload_worker(&tracker, shutdown.clone(), store.clone(), config);
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
cleanup_worker(shutdown, tracker).await;
|
||||
|
||||
let recorded = captured.lock().await;
|
||||
assert_eq!(recorded.len(), 3);
|
||||
let paths: Vec<&str> = recorded.iter().map(|r| r.path.as_str()).collect();
|
||||
let qmdl_path = format!("dav/{}.qmdl", entry_name);
|
||||
let ndjson_path = format!("dav/{}.ndjson", entry_name);
|
||||
let gps_path = format!("dav/{}-gps.ndjson", entry_name);
|
||||
assert!(paths.contains(&qmdl_path.as_str()));
|
||||
assert!(paths.contains(&ndjson_path.as_str()));
|
||||
assert!(paths.contains(&gps_path.as_str()));
|
||||
for put in recorded.iter() {
|
||||
assert_eq!(put.auth.as_deref(), Some("Basic dXNlcjpwYXNzd29yZA=="));
|
||||
}
|
||||
let qmdl_body = recorded
|
||||
.iter()
|
||||
.find(|r| r.path == qmdl_path)
|
||||
.unwrap()
|
||||
.body
|
||||
.clone();
|
||||
let ndjson_body = recorded
|
||||
.iter()
|
||||
.find(|r| r.path == ndjson_path)
|
||||
.unwrap()
|
||||
.body
|
||||
.clone();
|
||||
drop(recorded);
|
||||
assert_eq!(qmdl_body, b"fake qmdl payload");
|
||||
assert_eq!(ndjson_body, b"fake ndjson payload");
|
||||
|
||||
let store_read = store.read().await;
|
||||
let (_, entry) = store_read.entry_for_name(&entry_name).unwrap();
|
||||
assert!(entry.upload_time.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_webdav_upload_worker_deletes_when_configured() {
|
||||
let (captured, url) = setup_webdav_server().await;
|
||||
|
||||
let dir = Builder::new().prefix("webdav_test").tempdir().unwrap();
|
||||
let (store, entry_name) = make_store_with_closed_entry(dir.path()).await;
|
||||
|
||||
let shutdown = CancellationToken::new();
|
||||
let tracker = TaskTracker::new();
|
||||
let config = WebdavUploadWorkerConfig {
|
||||
poll_interval: Duration::from_millis(50),
|
||||
min_age: TimeDelta::seconds(-1),
|
||||
url,
|
||||
username: None,
|
||||
password: None,
|
||||
timeout: Duration::from_secs(1),
|
||||
delete_on_upload: true,
|
||||
};
|
||||
|
||||
run_webdav_upload_worker(&tracker, shutdown.clone(), store.clone(), config);
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
cleanup_worker(shutdown, tracker).await;
|
||||
|
||||
assert_eq!(captured.lock().await.len(), 3);
|
||||
|
||||
let store_read = store.read().await;
|
||||
assert!(store_read.entry_for_name(&entry_name).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_webdav_upload_worker_respects_min_age() {
|
||||
let (captured, url) = setup_webdav_server().await;
|
||||
|
||||
let dir = Builder::new().prefix("webdav_test").tempdir().unwrap();
|
||||
let (store, entry_name) = make_store_with_closed_entry(dir.path()).await;
|
||||
|
||||
let shutdown = CancellationToken::new();
|
||||
let tracker = TaskTracker::new();
|
||||
let config = WebdavUploadWorkerConfig {
|
||||
poll_interval: Duration::from_millis(50),
|
||||
min_age: TimeDelta::seconds(3600),
|
||||
url,
|
||||
username: None,
|
||||
password: None,
|
||||
timeout: Duration::from_secs(1),
|
||||
delete_on_upload: false,
|
||||
};
|
||||
|
||||
run_webdav_upload_worker(&tracker, shutdown.clone(), store.clone(), config);
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
cleanup_worker(shutdown, tracker).await;
|
||||
|
||||
assert!(captured.lock().await.is_empty());
|
||||
|
||||
let store_read = store.read().await;
|
||||
let (_, entry) = store_read.entry_for_name(&entry_name).unwrap();
|
||||
assert!(entry.upload_time.is_none());
|
||||
}
|
||||
}
|
||||
185
daemon/tests/smoke.rs
Normal file
185
daemon/tests/smoke.rs
Normal file
@@ -0,0 +1,185 @@
|
||||
use std::io::Read;
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::process::{Child, Command, ExitStatus, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
const STARTUP_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
struct DaemonGuard {
|
||||
child: Option<Child>,
|
||||
stderr: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl DaemonGuard {
|
||||
fn stderr_dump(&self) -> String {
|
||||
let buf = self.stderr.lock().unwrap();
|
||||
String::from_utf8_lossy(&buf).into_owned()
|
||||
}
|
||||
|
||||
fn shutdown(&mut self, timeout: Duration) -> std::io::Result<ExitStatus> {
|
||||
let mut child = self
|
||||
.child
|
||||
.take()
|
||||
.expect("daemon already shut down or never started");
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let pid = child.id() as libc::pid_t;
|
||||
// SAFETY: child.id() returns the OS pid of a process we own.
|
||||
unsafe { libc::kill(pid, libc::SIGINT) };
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = child.kill();
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
match child.try_wait()? {
|
||||
Some(status) => return Ok(status),
|
||||
None => {
|
||||
if start.elapsed() >= timeout {
|
||||
let _ = child.kill();
|
||||
return child.wait();
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DaemonGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(mut child) = self.child.take() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pick_free_port() -> u16 {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port");
|
||||
listener.local_addr().expect("local_addr").port()
|
||||
}
|
||||
|
||||
fn wait_for_port(port: u16, timeout: Duration) -> bool {
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < timeout {
|
||||
if TcpStream::connect(("127.0.0.1", port)).is_ok() {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn daemon_serves_index_and_api() {
|
||||
let port = pick_free_port();
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let qmdl_dir = tmp.path().join("qmdl");
|
||||
std::fs::create_dir(&qmdl_dir).unwrap();
|
||||
// The daemon refuses to create a store in debug_mode, so seed an empty
|
||||
// manifest. See init_qmdl_store in daemon/src/main.rs.
|
||||
std::fs::write(qmdl_dir.join("manifest.toml"), "entries = []\n").unwrap();
|
||||
|
||||
let config_path = tmp.path().join("config.toml");
|
||||
std::fs::write(
|
||||
&config_path,
|
||||
format!(
|
||||
"qmdl_store_path = \"{}\"\nport = {}\ndebug_mode = true\n",
|
||||
qmdl_dir.display(),
|
||||
port,
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let daemon_bin = env!("CARGO_BIN_EXE_rayhunter-daemon");
|
||||
let mut child = Command::new(daemon_bin)
|
||||
.arg(&config_path)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("failed to spawn daemon");
|
||||
|
||||
let stderr_buf: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
if let Some(mut pipe) = child.stderr.take() {
|
||||
let sink = stderr_buf.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut chunk = [0u8; 4096];
|
||||
loop {
|
||||
match pipe.read(&mut chunk) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => sink.lock().unwrap().extend_from_slice(&chunk[..n]),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let mut guard = DaemonGuard {
|
||||
child: Some(child),
|
||||
stderr: stderr_buf,
|
||||
};
|
||||
|
||||
if !wait_for_port(port, STARTUP_TIMEOUT) {
|
||||
panic!(
|
||||
"daemon did not start listening on {port} within {STARTUP_TIMEOUT:?}\n--- daemon stderr ---\n{}",
|
||||
guard.stderr_dump(),
|
||||
);
|
||||
}
|
||||
|
||||
// reqwest's rustls backend gets pulled in via feature unification with the
|
||||
// daemon's production deps. The test process needs its own crypto provider.
|
||||
rayhunter_daemon::crypto_provider::install_default();
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.gzip(true)
|
||||
.build()
|
||||
.unwrap();
|
||||
let base = format!("http://127.0.0.1:{port}");
|
||||
|
||||
let resp = client
|
||||
.get(format!("{base}/index.html"))
|
||||
.send()
|
||||
.await
|
||||
.expect("GET /index.html failed");
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"GET /index.html returned {}",
|
||||
resp.status(),
|
||||
);
|
||||
let body = resp.text().await.expect("could not read index.html body");
|
||||
assert!(
|
||||
body.contains("Rayhunter"),
|
||||
"decompressed index.html body did not contain 'Rayhunter' marker (len={})",
|
||||
body.len(),
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.get(format!("{base}/api/qmdl-manifest"))
|
||||
.send()
|
||||
.await
|
||||
.expect("GET /api/qmdl-manifest failed");
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"GET /api/qmdl-manifest returned {}",
|
||||
resp.status(),
|
||||
);
|
||||
|
||||
let status = guard
|
||||
.shutdown(SHUTDOWN_TIMEOUT)
|
||||
.expect("waiting for daemon exit failed");
|
||||
assert!(
|
||||
status.success(),
|
||||
"daemon did not exit cleanly after SIGINT: {status}\n--- daemon stderr ---\n{}",
|
||||
guard.stderr_dump(),
|
||||
);
|
||||
}
|
||||
3
daemon/web/.gitignore
vendored
3
daemon/web/.gitignore
vendored
@@ -19,6 +19,3 @@ Thumbs.db
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
@@ -22,7 +22,7 @@ export default ts.config(
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.svelte'],
|
||||
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
@@ -37,6 +37,22 @@ export default ts.config(
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/naming-convention': [
|
||||
'error',
|
||||
{
|
||||
selector: 'function',
|
||||
format: ['snake_case'],
|
||||
},
|
||||
{
|
||||
selector: 'method',
|
||||
format: ['snake_case'],
|
||||
},
|
||||
],
|
||||
// these rules should eventually be enabled, just disabled them to
|
||||
// make dependency upgrades easier.
|
||||
'svelte/prefer-svelte-reactivity': 'off',
|
||||
'svelte/require-each-key': 'off',
|
||||
'svelte/no-navigation-without-resolve': 'off',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
3570
daemon/web/package-lock.json
generated
Normal file
3570
daemon/web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -15,24 +15,26 @@
|
||||
"fix": "eslint --fix ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@sveltejs/adapter-auto": "^7.0.1",
|
||||
"@sveltejs/adapter-static": "^3.0.5",
|
||||
"@sveltejs/kit": "^2.13.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@sveltejs/kit": "^2.60.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@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"
|
||||
"@types/node": "^25.6.0",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.17.1",
|
||||
"globals": "^17.5.0",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-svelte": "^3.5.1",
|
||||
"svelte": "^5.55.7",
|
||||
"svelte-check": "^4.4.6",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.0",
|
||||
"vite": "^8.0.10",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -1,3 +1,16 @@
|
||||
@import 'tailwindcss/base';
|
||||
@import 'tailwindcss/components';
|
||||
@import 'tailwindcss/utilities';
|
||||
@import 'tailwindcss';
|
||||
|
||||
@theme {
|
||||
--color-rayhunter-blue: #4e4eb1;
|
||||
--color-rayhunter-dark-blue: #3f3da0;
|
||||
--color-rayhunter-green: #94ea18;
|
||||
}
|
||||
|
||||
/* v4 dropped the v3 preflight rule that set `cursor: pointer` on buttons.
|
||||
* Restore it so enabled buttons get the pointer cursor. */
|
||||
@layer base {
|
||||
button:not(:disabled),
|
||||
[role='button']:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<body data-sveltekit-preload-data="hover" style="width: 100%">
|
||||
<div style="display: contents" class="m-4 xl:m-8">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
24
daemon/web/src/lib/action_errors.svelte.ts
Normal file
24
daemon/web/src/lib/action_errors.svelte.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export class ActionError extends Error {
|
||||
// The number of this an identical error has happened.
|
||||
// This is shown as a number next to the error in the UI.
|
||||
times = $state(1);
|
||||
|
||||
constructor(message: string, cause: Error) {
|
||||
super(message);
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
|
||||
export const action_errors: ActionError[] = $state([]);
|
||||
|
||||
export function add_error(e: Error, msg: string): void {
|
||||
for (const existing of action_errors) {
|
||||
if (existing.message === msg) {
|
||||
existing.times += 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
const action_error = new ActionError(msg, e);
|
||||
action_errors.unshift(action_error);
|
||||
console.log(action_errors.length);
|
||||
}
|
||||
@@ -1,43 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AnalysisRowType, EventType, parse_finished_report, Severity } from './analysis.svelte';
|
||||
import { AnalysisRowType, parse_finished_report } from './analysis.svelte';
|
||||
import { type NewlineDeliminatedJson } from './ndjson';
|
||||
|
||||
const SAMPLE_V1_REPORT_NDJSON: NewlineDeliminatedJson = [
|
||||
{
|
||||
analyzers: [
|
||||
{
|
||||
name: 'Analyzer 1',
|
||||
description: 'A first analyzer',
|
||||
},
|
||||
{
|
||||
name: 'Analyzer 2',
|
||||
description: 'A second analyzer',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
timestamp: '2024-10-08T13:25:43.011689003-07:00',
|
||||
skipped_message_reasons: ['The reason why the message was skipped'],
|
||||
analysis: [],
|
||||
},
|
||||
{
|
||||
timestamp: '2024-10-08T13:25:43.480872496-07:00',
|
||||
skipped_message_reasons: [],
|
||||
analysis: [
|
||||
{
|
||||
timestamp: '2024-08-19T03:33:54.318Z',
|
||||
events: [
|
||||
null,
|
||||
{
|
||||
event_type: { type: 'QualitativeWarning', severity: 'Low' },
|
||||
message: 'Something nasty happened',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const SAMPLE_V2_REPORT_NDJSON: NewlineDeliminatedJson = [
|
||||
{
|
||||
analyzers: [
|
||||
@@ -62,7 +26,7 @@ const SAMPLE_V2_REPORT_NDJSON: NewlineDeliminatedJson = [
|
||||
events: [
|
||||
null,
|
||||
{
|
||||
event_type: { type: 'QualitativeWarning', severity: 'Low' },
|
||||
event_type: 'Low',
|
||||
message: 'Something nasty happened',
|
||||
},
|
||||
],
|
||||
@@ -70,40 +34,6 @@ const SAMPLE_V2_REPORT_NDJSON: NewlineDeliminatedJson = [
|
||||
];
|
||||
|
||||
describe('analysis report parsing', () => {
|
||||
it('parses v1 example analysis', () => {
|
||||
const report = parse_finished_report(SAMPLE_V1_REPORT_NDJSON);
|
||||
expect(report.metadata.report_version).toEqual(1);
|
||||
expect(report.metadata.analyzers).toEqual([
|
||||
{
|
||||
name: 'Analyzer 1',
|
||||
description: 'A first analyzer',
|
||||
version: 0,
|
||||
},
|
||||
{
|
||||
name: 'Analyzer 2',
|
||||
description: 'A second analyzer',
|
||||
version: 0,
|
||||
},
|
||||
]);
|
||||
expect(report.rows).toHaveLength(2);
|
||||
expect(report.rows[0].type).toBe(AnalysisRowType.Skipped);
|
||||
if (report.rows[1].type === AnalysisRowType.Analysis) {
|
||||
const row = report.rows[1];
|
||||
expect(row.events).toHaveLength(2);
|
||||
expect(row.events[0]).toBeNull();
|
||||
const event = row.events[1];
|
||||
const expected_timestamp = new Date('2024-08-19T03:33:54.318Z');
|
||||
expect(row.packet_timestamp.getTime()).toEqual(expected_timestamp.getTime());
|
||||
if (event !== null && event.type === EventType.Warning) {
|
||||
expect(event.severity).toEqual(Severity.Low);
|
||||
} else {
|
||||
throw 'wrong event type';
|
||||
}
|
||||
} else {
|
||||
throw 'wrong row type';
|
||||
}
|
||||
});
|
||||
|
||||
it('parses v2 example analysis', () => {
|
||||
const report = parse_finished_report(SAMPLE_V2_REPORT_NDJSON);
|
||||
expect(report.metadata.report_version).toEqual(2);
|
||||
@@ -128,11 +58,7 @@ describe('analysis report parsing', () => {
|
||||
const event = row.events[1];
|
||||
const expected_timestamp = new Date('2024-08-19T03:33:54.318Z');
|
||||
expect(row.packet_timestamp.getTime()).toEqual(expected_timestamp.getTime());
|
||||
if (event !== null && event.type === EventType.Warning) {
|
||||
expect(event.severity).toEqual(Severity.Low);
|
||||
} else {
|
||||
throw 'wrong event type';
|
||||
}
|
||||
expect(event!.event_type).toEqual('Low');
|
||||
} else {
|
||||
throw 'wrong row type';
|
||||
}
|
||||
|
||||
@@ -21,17 +21,7 @@ export class ReportMetadata {
|
||||
constructor(ndjson: any) {
|
||||
this.analyzers = ndjson.analyzers;
|
||||
this.rayhunter = ndjson.rayhunter;
|
||||
if (ndjson.report_version === undefined) {
|
||||
this.report_version = 1;
|
||||
// we consider our legacy (unversioned) heuristics to be v0 --
|
||||
// this'll let us clearly differentiate some known false-positive
|
||||
// results from the pre-versioned era from v1 heuristics
|
||||
this.analyzers.forEach((analyzer) => {
|
||||
analyzer.version = 0;
|
||||
});
|
||||
} else {
|
||||
this.report_version = ndjson.report_version;
|
||||
}
|
||||
this.report_version = ndjson.report_version || 2; // Default to v2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,77 +54,22 @@ export type PacketAnalysis = {
|
||||
events: Event[];
|
||||
};
|
||||
|
||||
export type Event = QualitativeWarning | InformationalEvent | null;
|
||||
export enum EventType {
|
||||
Informational,
|
||||
Warning,
|
||||
}
|
||||
export type EventType = 'Informational' | 'Low' | 'Medium' | 'High';
|
||||
|
||||
export type QualitativeWarning = {
|
||||
type: EventType.Warning;
|
||||
severity: Severity;
|
||||
export type Event = {
|
||||
event_type: EventType;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export enum Severity {
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
}
|
||||
|
||||
export type InformationalEvent = {
|
||||
type: EventType.Informational;
|
||||
message: string;
|
||||
};
|
||||
} | null;
|
||||
|
||||
function get_event(event_json: any): Event {
|
||||
if (event_json.event_type.type === 'Informational') {
|
||||
return {
|
||||
type: EventType.Informational,
|
||||
message: event_json.message,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: EventType.Warning,
|
||||
severity:
|
||||
event_json.event_type.severity === 'High'
|
||||
? Severity.High
|
||||
: event_json.event_type.severity === 'Medium'
|
||||
? Severity.Medium
|
||||
: Severity.Low,
|
||||
message: event_json.message,
|
||||
};
|
||||
if (!['Informational', 'Low', 'Medium', 'High'].includes(event_json.event_type)) {
|
||||
throw `Invalid/unhandled event type: ${event_json.event_type}`;
|
||||
}
|
||||
|
||||
return event_json;
|
||||
}
|
||||
|
||||
function get_v1_rows(row_jsons: any[]): AnalysisRow[] {
|
||||
const rows: AnalysisRow[] = [];
|
||||
for (const row_json of row_jsons) {
|
||||
for (const reason of row_json.skipped_message_reasons) {
|
||||
rows.push({
|
||||
type: AnalysisRowType.Skipped,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
for (const analysis_json of row_json.analysis) {
|
||||
const events: Event[] = analysis_json.events.map((event_json: any): Event | null => {
|
||||
if (event_json === null) {
|
||||
return null;
|
||||
} else {
|
||||
return get_event(event_json);
|
||||
}
|
||||
});
|
||||
rows.push({
|
||||
type: AnalysisRowType.Analysis,
|
||||
packet_timestamp: new Date(analysis_json.timestamp),
|
||||
events,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function get_v2_rows(row_jsons: any[]): AnalysisRow[] {
|
||||
function get_rows(row_jsons: any[]): AnalysisRow[] {
|
||||
const rows: AnalysisRow[] = [];
|
||||
for (const row_json of row_jsons) {
|
||||
if (row_json.skipped_message_reason) {
|
||||
@@ -170,7 +105,7 @@ function get_report_stats(rows: AnalysisRow[]): ReportStatistics {
|
||||
} else {
|
||||
for (const event of row.events) {
|
||||
if (event !== null) {
|
||||
if (event.type === EventType.Informational) {
|
||||
if (event.event_type === 'Informational') {
|
||||
num_informational_logs++;
|
||||
} else {
|
||||
num_warnings++;
|
||||
@@ -188,12 +123,7 @@ function get_report_stats(rows: AnalysisRow[]): ReportStatistics {
|
||||
|
||||
export function parse_finished_report(report_json: NewlineDeliminatedJson): AnalysisReport {
|
||||
const metadata = new ReportMetadata(report_json[0]);
|
||||
let rows;
|
||||
if (metadata.report_version === 1) {
|
||||
rows = get_v1_rows(report_json.slice(1));
|
||||
} else {
|
||||
rows = get_v2_rows(report_json.slice(1));
|
||||
}
|
||||
const rows = get_rows(report_json.slice(1));
|
||||
const statistics = get_report_stats(rows);
|
||||
return {
|
||||
statistics,
|
||||
|
||||
@@ -23,11 +23,9 @@ export type AnalysisResult = {
|
||||
};
|
||||
|
||||
export class AnalysisManager {
|
||||
public status: Map<string, AnalysisStatus> = new Map();
|
||||
public reports: Map<string, AnalysisReport | string> = new Map();
|
||||
|
||||
public async run_analysis(name: string) {
|
||||
await req('POST', `/api/analysis/${name}`);
|
||||
public status: Map<string, AnalysisStatus> = $state(new Map());
|
||||
public reports: Map<string, AnalysisReport | string> = $state(new Map());
|
||||
public set_queued_status(name: string) {
|
||||
this.status.set(name, AnalysisStatus.Queued);
|
||||
this.reports.delete(name);
|
||||
}
|
||||
|
||||
100
daemon/web/src/lib/components/ActionErrors.svelte
Normal file
100
daemon/web/src/lib/components/ActionErrors.svelte
Normal file
@@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
import { action_errors } from '../action_errors.svelte';
|
||||
|
||||
let pos = $state(0);
|
||||
let current_error = $derived(action_errors[pos]);
|
||||
|
||||
function prev_error() {
|
||||
if (pos > 0) pos -= 1;
|
||||
else pos = action_errors.length - 1;
|
||||
}
|
||||
function next_error() {
|
||||
if (pos + 1 < action_errors.length) pos += 1;
|
||||
else pos = 0;
|
||||
}
|
||||
function clear_errors() {
|
||||
pos = 0;
|
||||
action_errors.length = 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if action_errors.length > 0}
|
||||
<div
|
||||
class="bg-red-100 border-red-100 drop-shadow-sm p-4 flex flex-col gap-2
|
||||
border rounded-md flex-1 justify-between fixed z-10 right-3 bottom-3 ml-3"
|
||||
>
|
||||
<div class="flex flex-row justify-between">
|
||||
<span class="text-xl font-bold mb-2 mr-5 flex flex-row items-center gap-1 text-red-600">
|
||||
<svg
|
||||
class="w-6 h-6 text-red-600"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm11-4a1 1 0 1 0-2 0v5a1 1 0 1 0 2 0V8Zm-1 7a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H12Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Error Completing Action {current_error.times > 1 ? `x${current_error.times}` : ''}
|
||||
</span>
|
||||
<div class="flex items-center mb-2">
|
||||
{#if action_errors.length > 1}
|
||||
<span>{pos + 1}/{action_errors.length}</span>
|
||||
<button title="previous error" aria-label="previous error" onclick={prev_error}>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="m 15.499979,19.499979 -6.9999997,-7 6.9999997,-6.9999997"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button title="next error" aria-label="next error" onclick={next_error}>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="m 8.5000207,5.4999793 7.0000003,6.9999997 -7.0000003,7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<button title="clear errors" aria-label="clear errors" onclick={clear_errors}>
|
||||
<svg style="width:24px;height:24px" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span>{current_error.message}</span>
|
||||
{#if current_error.cause}
|
||||
<details>
|
||||
<summary>Details</summary>
|
||||
<code>{current_error.cause}</code>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -35,15 +35,43 @@
|
||||
return finished && report_available;
|
||||
});
|
||||
|
||||
let button_class = $derived(ready ? 'text-blue-600 border rounded-full px-2' : '');
|
||||
let button_class = $derived.by(() => {
|
||||
if (!ready) {
|
||||
return 'text-gray-700';
|
||||
} else if ((entry.get_num_warnings() || 0) < 1) {
|
||||
return 'text-green-700 border-green-500 bg-green-200 text-blue-600 border rounded-full px-2';
|
||||
} else {
|
||||
return 'text-red-700 border-red-500 bg-red-200 text-blue-600 border rounded-full px-2';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<button class="flex flex-row gap-1 lg:gap-2" disabled={!ready} {onclick}>
|
||||
<span
|
||||
class="{button_class} {(entry.get_num_warnings() || 0) < 1
|
||||
? 'text-green-700 border-green-500 bg-green-200'
|
||||
: 'text-red-700 border-red-500 bg-red-200'}">{summary}</span
|
||||
>
|
||||
<span class="flex flex-row items-center gap-1">
|
||||
{#if entry.analysis_status === AnalysisStatus.Queued || entry.analysis_status === AnalysisStatus.Running || (entry.analysis_status === AnalysisStatus.Finished && entry.analysis_report === undefined)}
|
||||
<svg
|
||||
class="animate-spin h-4 w-4 text-blue-600"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{/if}
|
||||
<span class={button_class}>{summary}</span>
|
||||
</span>
|
||||
<svg
|
||||
class="w-6 h-6 text-gray-800 transition-transform {analysis_visible ? 'rotate-180' : ''}"
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { AnalysisRowType, EventType, type AnalysisReport } from '$lib/analysis.svelte';
|
||||
import { AnalysisRowType, type AnalysisReport } from '$lib/analysis.svelte';
|
||||
let {
|
||||
report,
|
||||
}: {
|
||||
@@ -11,7 +11,7 @@
|
||||
dateStyle: 'short',
|
||||
});
|
||||
|
||||
const analyzers = report.metadata.analyzers;
|
||||
const analyzers = $derived(report.metadata.analyzers);
|
||||
|
||||
const skipped_messages: Map<string, number> = $derived.by(() => {
|
||||
let map = new Map();
|
||||
@@ -33,69 +33,71 @@
|
||||
{#if report.statistics.num_warnings === 0 && report.statistics.num_informational_logs === 0}
|
||||
<p>Nothing to show!</p>
|
||||
{:else}
|
||||
<table class="table-auto text-left">
|
||||
<thead class="p-2">
|
||||
<tr class="bg-gray-300">
|
||||
<th class="p-2">Timestamp</th>
|
||||
<th class="p-2">Heuristic</th>
|
||||
<th class="p-2">Warning</th>
|
||||
<th class="p-2">Severity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each report.rows as row}
|
||||
{#if row.type === AnalysisRowType.Analysis}
|
||||
{@const parsed_date = new Date(row.packet_timestamp)}
|
||||
{#each row.events.filter((e) => e !== null) as event, i}
|
||||
{@const analyzer = analyzers[i]}
|
||||
<tr class="even:bg-gray-200 odd:bg-white">
|
||||
{#if event.type === EventType.Warning}
|
||||
{@const severity = ['Low', 'Medium', 'High'][event.severity]}
|
||||
{@const severity_class = [
|
||||
'bg-red-200',
|
||||
'bg-red-400',
|
||||
'bg-red-600',
|
||||
][event.severity]}
|
||||
<td class="p-2">{date_formatter.format(parsed_date)}</td>
|
||||
<td class="p-2">{analyzer.name} v{analyzer.version}</td>
|
||||
<td class="p-2">{event.message}</td>
|
||||
<td class="p-2 {severity_class} text-center">{severity}</td>
|
||||
{:else if event.type === EventType.Informational}
|
||||
<td class="p-2">{date_formatter.format(parsed_date)}</td>
|
||||
<td class="p-2">{analyzer.name} v{analyzer.version}</td>
|
||||
<td class="p-2">{event.message}</td>
|
||||
<td class="p-2">Info</td>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table-auto text-left">
|
||||
<thead class="p-2">
|
||||
<tr class="bg-gray-300">
|
||||
<th class="p-2">Timestamp</th>
|
||||
<th class="p-2">Heuristic</th>
|
||||
<th class="p-2">Warning</th>
|
||||
<th class="p-2">Severity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each report.rows as row}
|
||||
{#if row.type === AnalysisRowType.Analysis}
|
||||
{@const parsed_date = new Date(row.packet_timestamp)}
|
||||
{#each row.events as event, analyzerIndex}
|
||||
{#if event !== null}
|
||||
{@const analyzer = analyzers[analyzerIndex]}
|
||||
{@const event_type_class = {
|
||||
Informational: '',
|
||||
Low: 'bg-yellow-200',
|
||||
Medium: 'bg-orange-400',
|
||||
High: 'bg-red-600',
|
||||
}[event.event_type]}
|
||||
<tr class="even:bg-gray-200 odd:bg-white">
|
||||
<td class="p-2">{date_formatter.format(parsed_date)}</td>
|
||||
<td class="p-2">{analyzer.name} v{analyzer.version}</td>
|
||||
<td class="p-2">{event.message}</td>
|
||||
<td class="p-2 {event_type_class} text-center"
|
||||
>{event.event_type}</td
|
||||
>
|
||||
</tr>
|
||||
{/if}
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if report.statistics.num_skipped_packets > 0}
|
||||
<div>
|
||||
<p class="text-lg underline">Unparsed Messages</p>
|
||||
<p>
|
||||
These are due to a limitation or bug in Rayhunter's parser, and aren't ususally a
|
||||
problem.
|
||||
These are due to a limitation or bug in Rayhunter's parser, and aren't usually a
|
||||
problem. We'll not accept bug reports about them unless something else is going wrong
|
||||
(such as false-positives or definite false-negatives)
|
||||
</p>
|
||||
<table class="table-auto text-left">
|
||||
<thead class="p-2">
|
||||
<tr class="bg-gray-300">
|
||||
<th scope="col" class="p-2">Total Msgs Affected</th>
|
||||
<th scope="col">Reason/Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each skipped_messages.entries() as [message, count]}
|
||||
<tr class="even:bg-gray-200 odd:bg-white">
|
||||
<td class="text-center">{count}</td>
|
||||
<td>{message}</td>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table-auto text-left">
|
||||
<thead class="p-2">
|
||||
<tr class="bg-gray-300">
|
||||
<th scope="col" class="p-2">Total Msgs Affected</th>
|
||||
<th scope="col">Reason/Error</th>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each skipped_messages.entries() as [message, count]}
|
||||
<tr class="even:bg-gray-200 odd:bg-white">
|
||||
<td class="text-center">{count}</td>
|
||||
<td>{message}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { type ReportMetadata } from '$lib/analysis.svelte';
|
||||
import type { ManifestEntry } from '$lib/manifest.svelte';
|
||||
import { gps_mode_label } from '$lib/utils.svelte';
|
||||
import { AnalysisManager } from '$lib/analysisManager.svelte';
|
||||
import AnalysisTable from './AnalysisTable.svelte';
|
||||
import ReAnalyzeButton from './ReAnalyzeButton.svelte';
|
||||
let {
|
||||
entry,
|
||||
manager,
|
||||
current,
|
||||
}: {
|
||||
entry: ManifestEntry;
|
||||
manager: AnalysisManager;
|
||||
current: boolean;
|
||||
} = $props();
|
||||
|
||||
const date_formatter = new Intl.DateTimeFormat(undefined, {
|
||||
timeStyle: 'long',
|
||||
dateStyle: 'short',
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="container mt-2">
|
||||
@@ -16,26 +28,61 @@
|
||||
<p>Error getting analysis report: {entry.analysis_report}</p>
|
||||
{:else}
|
||||
{@const metadata: ReportMetadata = entry.analysis_report.metadata}
|
||||
{@const numWarnings: number = entry.get_num_warnings() || 0}
|
||||
<div class="flex flex-col gap-2">
|
||||
{#if !!numWarnings || !current}
|
||||
<div class="flex flex-row justify-between items-center">
|
||||
{#if !!numWarnings}
|
||||
<div
|
||||
class="text-red-700 border-red-500 border rounded-lg text-blue-600 px-2 py-1 mr-12"
|
||||
>
|
||||
Your Rayhunter device raised {`${numWarnings}`} warning{`${
|
||||
numWarnings > 1 ? 's' : ''
|
||||
}`}!
|
||||
<a
|
||||
href="https://efforg.github.io/rayhunter/faq.html#red"
|
||||
class="text-blue-600 underline">Read the FAQ</a
|
||||
> to learn what you can do about it
|
||||
</div>
|
||||
{/if}
|
||||
{#if !current}
|
||||
<ReAnalyzeButton {entry} {manager} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if entry.analysis_report.rows.length > 0}
|
||||
<AnalysisTable report={entry.analysis_report} />
|
||||
{:else}
|
||||
<p>No warnings to display!</p>
|
||||
{/if}
|
||||
{#if metadata !== undefined && metadata.rayhunter !== undefined}
|
||||
<div>
|
||||
<p class="text-lg underline">Metadata</p>
|
||||
<p>Analysis by Rayhunter version {metadata.rayhunter.rayhunter_version}</p>
|
||||
<div>
|
||||
<p class="text-lg underline">Metadata</p>
|
||||
{#if metadata !== undefined && metadata.rayhunter !== undefined}
|
||||
<p><b>Rayhunter version:</b> {metadata.rayhunter.rayhunter_version}</p>
|
||||
<p><b>Device system OS:</b> {metadata.rayhunter.system_os}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<p>N/A (analysis generated by an older version of rayhunter)</p>
|
||||
{/if}
|
||||
{#if entry.upload_time}
|
||||
<p>
|
||||
<b>WebDAV uploaded at:</b>
|
||||
<span class="text-green-700"
|
||||
>{date_formatter.format(entry.upload_time)}</span
|
||||
>
|
||||
</p>
|
||||
{/if}
|
||||
<p>
|
||||
<b>GPS Mode:</b>
|
||||
{gps_mode_label(entry.gps_mode)}
|
||||
</p>
|
||||
</div>
|
||||
{#if metadata && metadata.analyzers}
|
||||
<div>
|
||||
<p class="text-lg underline">Analyzers</p>
|
||||
<p class="text-lg underline">Enabled Analyzers</p>
|
||||
{#each metadata.analyzers as analyzer}
|
||||
<p><b>{analyzer.name}:</b> {analyzer.description}</p>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p>N/A (analysis generated by an older version of rayhunter)</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
100
daemon/web/src/lib/components/ApiRequestButton.svelte
Normal file
100
daemon/web/src/lib/components/ApiRequestButton.svelte
Normal file
@@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
import { user_action_req } from '$lib/utils.svelte';
|
||||
|
||||
let {
|
||||
url,
|
||||
method = 'POST',
|
||||
label,
|
||||
loadingLabel,
|
||||
disabled = false,
|
||||
variant = 'blue',
|
||||
icon,
|
||||
onclick,
|
||||
ariaLabel,
|
||||
errorMessage,
|
||||
jsonBody,
|
||||
}: {
|
||||
url: string;
|
||||
method?: string;
|
||||
label: string;
|
||||
loadingLabel?: string;
|
||||
disabled?: boolean;
|
||||
variant?: 'blue' | 'red' | 'green';
|
||||
icon?: any; // Svelte snippet
|
||||
onclick?: () => void | Promise<void>;
|
||||
ariaLabel?: string;
|
||||
errorMessage?: string;
|
||||
jsonBody?: unknown;
|
||||
} = $props();
|
||||
|
||||
let is_requesting = $state(false);
|
||||
let is_disabled = $derived(disabled || is_requesting);
|
||||
|
||||
const variantClasses = {
|
||||
blue: {
|
||||
enabled: 'bg-blue-500 hover:bg-blue-700',
|
||||
disabled: 'bg-blue-500 opacity-50 cursor-not-allowed',
|
||||
},
|
||||
red: {
|
||||
enabled: 'bg-red-500 hover:bg-red-700',
|
||||
disabled: 'bg-red-500 opacity-50 cursor-not-allowed',
|
||||
},
|
||||
green: {
|
||||
enabled: 'bg-green-500 hover:bg-green-700',
|
||||
disabled: 'bg-green-500 opacity-50 cursor-not-allowed',
|
||||
},
|
||||
};
|
||||
|
||||
async function handle_click() {
|
||||
if (is_disabled) return;
|
||||
|
||||
is_requesting = true;
|
||||
try {
|
||||
await user_action_req(
|
||||
method,
|
||||
url,
|
||||
errorMessage ? errorMessage : 'Error performing action',
|
||||
jsonBody
|
||||
);
|
||||
if (onclick) {
|
||||
await onclick();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to ${method} ${url}:`, err);
|
||||
alert(`Request failed. Please try again.`);
|
||||
} finally {
|
||||
is_requesting = false;
|
||||
}
|
||||
}
|
||||
|
||||
let buttonClasses = $derived(
|
||||
is_disabled ? variantClasses[variant].disabled : variantClasses[variant].enabled
|
||||
);
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="text-white font-bold py-2 px-2 sm:px-4 rounded-md flex flex-row items-center gap-1 {buttonClasses}"
|
||||
onclick={handle_click}
|
||||
disabled={is_disabled}
|
||||
aria-label={ariaLabel || label}
|
||||
>
|
||||
<span>{is_requesting && loadingLabel ? loadingLabel : label}</span>
|
||||
{#if is_requesting}
|
||||
<svg
|
||||
class="w-4 h-4 text-white animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{:else if icon}
|
||||
{@render icon()}
|
||||
{/if}
|
||||
</button>
|
||||
121
daemon/web/src/lib/components/ClockDriftAlert.svelte
Normal file
121
daemon/web/src/lib/components/ClockDriftAlert.svelte
Normal file
@@ -0,0 +1,121 @@
|
||||
<script lang="ts">
|
||||
import { get_daemon_time } from '$lib/utils.svelte';
|
||||
import ApiRequestButton from './ApiRequestButton.svelte';
|
||||
|
||||
let show_alert = $state(false);
|
||||
let device_system_time = $state('');
|
||||
let device_adjusted_time = $state('');
|
||||
let browser_time = $state('');
|
||||
let has_offset = $state(false);
|
||||
let computed_offset = $state(0);
|
||||
let dismissed = $state(false);
|
||||
let check_completed = $state(false);
|
||||
|
||||
const DRIFT_THRESHOLD_SECONDS = 30;
|
||||
|
||||
function format_time(date: Date): string {
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
async function check_clock_drift() {
|
||||
if (check_completed) return;
|
||||
|
||||
try {
|
||||
const daemon_time_response = await get_daemon_time();
|
||||
const browser_now = new Date();
|
||||
const daemon_system_ms = new Date(daemon_time_response.system_time).getTime();
|
||||
const device_adjusted_ms = new Date(daemon_time_response.adjusted_time).getTime();
|
||||
const drift_seconds = Math.round((browser_now.getTime() - device_adjusted_ms) / 1000);
|
||||
|
||||
if (Math.abs(drift_seconds) > DRIFT_THRESHOLD_SECONDS && !dismissed) {
|
||||
device_system_time = format_time(new Date(daemon_time_response.system_time));
|
||||
device_adjusted_time = format_time(new Date(daemon_time_response.adjusted_time));
|
||||
browser_time = format_time(browser_now);
|
||||
has_offset = daemon_time_response.offset_seconds !== 0;
|
||||
// Calculate offset needed: browser_time - daemon_system_time
|
||||
computed_offset = Math.round((browser_now.getTime() - daemon_system_ms) / 1000);
|
||||
show_alert = true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to check clock drift:', err);
|
||||
}
|
||||
check_completed = true;
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
show_alert = false;
|
||||
dismissed = true;
|
||||
}
|
||||
|
||||
// Check clock drift on component mount
|
||||
$effect(() => {
|
||||
check_clock_drift();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if show_alert}
|
||||
<div
|
||||
class="bg-yellow-100 border-yellow-400 drop-shadow-sm p-4 flex flex-col gap-2 border rounded-md"
|
||||
>
|
||||
<span class="text-xl font-bold flex flex-row items-center gap-2 text-yellow-700">
|
||||
<svg
|
||||
class="w-6 h-6 text-yellow-600"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm11-4a1 1 0 1 0-2 0v4a1 1 0 0 0 .293.707l3 3a1 1 0 0 0 1.414-1.414L13 11.586V8Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Clock Mismatch Detected
|
||||
</span>
|
||||
<p>
|
||||
Rayhunter's clock doesn't match your browser's, and may be incorrect. This can happen if
|
||||
Rayhunter is unable to get the correct time from the internet. Consider synchronizing
|
||||
your browser's clock with the button below, or using another SIM card for better
|
||||
results.
|
||||
</p>
|
||||
<table class="w-fit">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="pr-2">Rayhunter clock (system):</td>
|
||||
<td class="font-mono">{device_system_time}</td>
|
||||
</tr>
|
||||
{#if has_offset}
|
||||
<tr>
|
||||
<td class="pr-2">Rayhunter clock (adjusted):</td>
|
||||
<td class="font-mono">{device_adjusted_time}</td>
|
||||
</tr>
|
||||
{/if}
|
||||
<tr>
|
||||
<td class="pr-2">Browser clock:</td>
|
||||
<td class="font-mono">{browser_time}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>Copy browser clock to device?</p>
|
||||
<div class="flex flex-row gap-2 justify-end">
|
||||
<button
|
||||
class="font-medium py-2 px-4 rounded-md border border-gray-400 hover:bg-yellow-200"
|
||||
onclick={dismiss}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
<ApiRequestButton
|
||||
url="/api/time-offset"
|
||||
label="Sync Clock"
|
||||
loadingLabel="Syncing..."
|
||||
variant="green"
|
||||
jsonBody={{ offset_seconds: computed_offset }}
|
||||
onclick={dismiss}
|
||||
errorMessage="Error syncing clock"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,20 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { get_config, set_config, type Config } from '../utils.svelte';
|
||||
import {
|
||||
get_config,
|
||||
set_config,
|
||||
test_notification,
|
||||
get_wifi_status,
|
||||
scan_wifi_networks,
|
||||
GpsMode,
|
||||
enabled_notifications,
|
||||
type Config,
|
||||
type WifiStatus,
|
||||
type WifiNetwork,
|
||||
} from '../utils.svelte';
|
||||
import Modal from './Modal.svelte';
|
||||
import ExpandableInput from './ExpandableInput.svelte';
|
||||
|
||||
let { shown = $bindable() }: { shown: boolean } = $props();
|
||||
let config = $state<Config | null>(null);
|
||||
|
||||
let loading = $state(false);
|
||||
let saving = $state(false);
|
||||
let testingNotification = $state(false);
|
||||
let message = $state('');
|
||||
let messageType = $state<'success' | 'error' | null>(null);
|
||||
let showConfig = $state(false);
|
||||
let testMessage = $state('');
|
||||
let testMessageType = $state<'success' | 'error' | null>(null);
|
||||
let wifiStatus = $state<WifiStatus | null>(null);
|
||||
let wifiStatusTimer = $state<ReturnType<typeof setInterval> | null>(null);
|
||||
let scanning = $state(false);
|
||||
let scanResults = $state<WifiNetwork[]>([]);
|
||||
let dnsServersInput = $state('');
|
||||
|
||||
async function loadConfig() {
|
||||
async function load_config() {
|
||||
try {
|
||||
loading = true;
|
||||
config = await get_config();
|
||||
dnsServersInput = config.dns_servers ? config.dns_servers.join(', ') : '';
|
||||
message = '';
|
||||
messageType = null;
|
||||
poll_wifi_status();
|
||||
} catch (error) {
|
||||
message = `Failed to load config: ${error}`;
|
||||
messageType = 'error';
|
||||
@@ -23,9 +46,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
async function save_config() {
|
||||
if (!config) return;
|
||||
|
||||
const trimmed = dnsServersInput.trim();
|
||||
config.dns_servers =
|
||||
trimmed.length > 0
|
||||
? trimmed
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
: null;
|
||||
|
||||
try {
|
||||
saving = true;
|
||||
await set_config(config);
|
||||
@@ -40,32 +72,84 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Load config when first shown
|
||||
$effect(() => {
|
||||
if (showConfig && !config) {
|
||||
loadConfig();
|
||||
async function poll_wifi_status() {
|
||||
if (wifiStatusTimer) clearInterval(wifiStatusTimer);
|
||||
try {
|
||||
wifiStatus = await get_wifi_status();
|
||||
} catch {
|
||||
wifiStatus = null;
|
||||
}
|
||||
wifiStatusTimer = setInterval(async () => {
|
||||
try {
|
||||
wifiStatus = await get_wifi_status();
|
||||
} catch {
|
||||
wifiStatus = null;
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
let scanError = $state('');
|
||||
|
||||
async function do_scan() {
|
||||
scanning = true;
|
||||
scanError = '';
|
||||
try {
|
||||
scanResults = await scan_wifi_networks();
|
||||
} catch (error) {
|
||||
scanResults = [];
|
||||
scanError = `Scan failed: ${error}`;
|
||||
} finally {
|
||||
scanning = false;
|
||||
}
|
||||
}
|
||||
|
||||
function select_network(network: WifiNetwork) {
|
||||
if (config) {
|
||||
config.wifi_ssid = network.ssid;
|
||||
config.wifi_password = '';
|
||||
config.wifi_security =
|
||||
network.security === 'WPA3' || network.security === 'WPA3 (transition)'
|
||||
? 'sae'
|
||||
: 'wpa_psk';
|
||||
scanResults = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function send_test_notification() {
|
||||
try {
|
||||
testingNotification = true;
|
||||
testMessage = '';
|
||||
testMessageType = null;
|
||||
await test_notification();
|
||||
testMessage = 'Test notification sent successfully!';
|
||||
testMessageType = 'success';
|
||||
} catch (error) {
|
||||
testMessage = `${error}`;
|
||||
testMessageType = 'error';
|
||||
} finally {
|
||||
testingNotification = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (shown && !config) {
|
||||
load_config();
|
||||
}
|
||||
if (!shown && wifiStatusTimer) {
|
||||
clearInterval(wifiStatusTimer);
|
||||
wifiStatusTimer = null;
|
||||
}
|
||||
return () => {
|
||||
if (wifiStatusTimer) {
|
||||
clearInterval(wifiStatusTimer);
|
||||
wifiStatusTimer = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-md p-6 m-4">
|
||||
<button
|
||||
class="w-full flex justify-between items-center text-xl font-bold mb-4 text-rayhunter-dark-blue hover:text-rayhunter-blue"
|
||||
onclick={() => (showConfig = !showConfig)}
|
||||
>
|
||||
<span>Configuration</span>
|
||||
<svg
|
||||
class="w-6 h-6 transition-transform {showConfig ? 'rotate-180' : ''}"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if showConfig}
|
||||
<Modal bind:shown title="Configuration">
|
||||
<div class="p-2">
|
||||
{#if loading}
|
||||
<div class="text-center py-4">Loading config...</div>
|
||||
{:else if config}
|
||||
@@ -73,7 +157,7 @@
|
||||
class="space-y-4"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
saveConfig();
|
||||
save_config();
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
@@ -83,13 +167,18 @@
|
||||
<select
|
||||
id="ui_level"
|
||||
bind:value={config.ui_level}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
|
||||
>
|
||||
<option value={0}>0 - Invisible mode</option>
|
||||
<option value={1}>1 - Subtle mode (colored line)</option>
|
||||
<option value={2}>2 - Demo mode (orca gif)</option>
|
||||
<option value={3}>3 - EFF logo</option>
|
||||
<option value={0}>Invisible mode</option>
|
||||
<option value={1}>Subtle mode (colored line)</option>
|
||||
<option value={2}>Demo mode (orca gif)</option>
|
||||
<option value={3}>EFF logo</option>
|
||||
<option value={4}>High visibility (full screen color)</option>
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Note: Rayhunter draws over the device's native UI, so some flickering is
|
||||
expected
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -102,12 +191,10 @@
|
||||
<select
|
||||
id="key_input_mode"
|
||||
bind:value={config.key_input_mode}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
|
||||
>
|
||||
<option value={0}>0 - Disable button control</option>
|
||||
<option value={1}
|
||||
>1 - Double-tap power button to start/stop recording</option
|
||||
>
|
||||
<option value={0}>Disable button control</option>
|
||||
<option value={1}>Double-tap power button to start new recording</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -117,7 +204,7 @@
|
||||
id="colorblind_mode"
|
||||
type="checkbox"
|
||||
bind:checked={config.colorblind_mode}
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
|
||||
/>
|
||||
<label for="colorblind_mode" class="ml-2 block text-sm text-gray-700">
|
||||
Colorblind Mode
|
||||
@@ -125,7 +212,451 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t pt-4 mt-6">
|
||||
<div class="border-t border-gray-200 pt-4 mt-6 space-y-3">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">Notification Settings</h3>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="auto_check_updates"
|
||||
type="checkbox"
|
||||
bind:checked={config.auto_check_updates}
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
|
||||
/>
|
||||
<label for="auto_check_updates" class="ml-2 block text-sm text-gray-700">
|
||||
Automatically check for software updates
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">
|
||||
When enabled, Rayhunter periodically checks GitHub for new releases and
|
||||
shows an update notice in the web UI.
|
||||
</p>
|
||||
|
||||
<ExpandableInput
|
||||
bind:value={config.ntfy_url}
|
||||
checkboxId="ntfy_enabled"
|
||||
inputId="ntfy_url"
|
||||
label="Enable ntfy notifications"
|
||||
inputLabel="ntfy URL"
|
||||
inputPlaceholder="https://ntfy.sh/my-rayhunter"
|
||||
inputHelp="Test button below uses the saved configuration URL, not the input above"
|
||||
>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={send_test_notification}
|
||||
disabled={testingNotification}
|
||||
class="bg-rayhunter-blue hover:bg-rayhunter-dark-blue disabled:opacity-50 disabled:cursor-not-allowed text-white font-bold py-2 px-4 rounded-md flex flex-row gap-1 items-center"
|
||||
>
|
||||
{#if testingNotification}
|
||||
<div
|
||||
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
Sending...
|
||||
{:else}
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
|
||||
></path>
|
||||
</svg>
|
||||
Send Test Notification
|
||||
{/if}
|
||||
</button>
|
||||
{#if testMessage}
|
||||
<div
|
||||
class="mt-2 p-2 rounded-sm text-sm {testMessageType === 'error'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-green-100 text-green-700'}"
|
||||
>
|
||||
{testMessage}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Enabled Notification Types
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enable_warning_notifications"
|
||||
value="Warning"
|
||||
bind:group={config.enabled_notifications}
|
||||
/>
|
||||
<label
|
||||
for="enable_warning_notifications"
|
||||
class="ml-2 block text-sm text-gray-700"
|
||||
>
|
||||
Warnings
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enable_lowbattery_notifications"
|
||||
value="LowBattery"
|
||||
bind:group={config.enabled_notifications}
|
||||
/>
|
||||
<label
|
||||
for="enable_lowbattery_notifications"
|
||||
class="ml-2 block text-sm text-gray-700"
|
||||
>
|
||||
Low Battery
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enable_update_notifications"
|
||||
value={enabled_notifications.Update}
|
||||
bind:group={config.enabled_notifications}
|
||||
/>
|
||||
<label
|
||||
for="enable_update_notifications"
|
||||
class="ml-2 block text-sm text-gray-700"
|
||||
>
|
||||
Software Updates
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</ExpandableInput>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 pt-4 mt-6 space-y-3">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">Storage Management</h3>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="min_space_to_start_recording_mb"
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Minimum Space to Start Recording (MB)
|
||||
</label>
|
||||
<input
|
||||
id="min_space_to_start_recording_mb"
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={config.min_space_to_start_recording_mb}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Recording will not start if less than this amount of disk space is free
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="min_space_to_continue_recording_mb"
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Minimum Space to Continue Recording (MB)
|
||||
</label>
|
||||
<input
|
||||
id="min_space_to_continue_recording_mb"
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={config.min_space_to_continue_recording_mb}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Recording will stop automatically if disk space drops below this level
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 pt-4 mt-6 space-y-3">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">WebDAV Upload</h3>
|
||||
<p class="text-xs text-gray-500">
|
||||
Once a recording has been closed for at least the configured age, both the
|
||||
.qmdl and .ndjson files are uploaded in the background to the WebDAV server.
|
||||
</p>
|
||||
|
||||
<ExpandableInput
|
||||
bind:value={config.webdav.url}
|
||||
checkboxId="webdav_enabled"
|
||||
inputId="webdav_url"
|
||||
label="Enable WebDAV upload"
|
||||
inputLabel="Server URL"
|
||||
inputPlaceholder="https://dav.example.com/rayhunter/"
|
||||
inputHelp="Files are uploaded via HTTP PUT under this base URL. No folders are created, and folders in this base URL are assumed to exist already."
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
for="webdav_username"
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="webdav_username"
|
||||
type="text"
|
||||
bind:value={config.webdav.username}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Optional. Leave blank for unauthenticated uploads.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="webdav_password"
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="webdav_password"
|
||||
type="password"
|
||||
bind:value={config.webdav.password}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
A password without a username will be rejected and the request will
|
||||
be sent unauthenticated.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="webdav_upload_timeout_secs"
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Upload Timeout (seconds)
|
||||
</label>
|
||||
<input
|
||||
id="webdav_upload_timeout_secs"
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={config.webdav.upload_timeout_secs}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="webdav_poll_interval_secs"
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Poll Interval (seconds)
|
||||
</label>
|
||||
<input
|
||||
id="webdav_poll_interval_secs"
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={config.webdav.poll_interval_secs}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
How often the worker checks for new entries to upload.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="webdav_min_age_secs"
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Minimum Age Before Upload (seconds)
|
||||
</label>
|
||||
<input
|
||||
id="webdav_min_age_secs"
|
||||
type="number"
|
||||
min="0"
|
||||
bind:value={config.webdav.min_age_secs}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
How long a recording must be closed before it becomes eligible for
|
||||
upload.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="webdav_delete_on_upload"
|
||||
type="checkbox"
|
||||
bind:checked={config.webdav.delete_on_upload}
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
|
||||
/>
|
||||
<label
|
||||
for="webdav_delete_on_upload"
|
||||
class="ml-2 block text-sm text-gray-700"
|
||||
>
|
||||
Delete on successful upload
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">
|
||||
When enabled, the local files are removed after a successful upload.
|
||||
Otherwise the manifest is just marked as uploaded.
|
||||
</p>
|
||||
</ExpandableInput>
|
||||
</div>
|
||||
|
||||
{#if config.device === 'orbic' || config.device === 'moxee' || config.device === 'tmobile' || config.device === 'wingtech'}
|
||||
<div class="border-t border-gray-200 pt-4 mt-6 space-y-3">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">WiFi Client Mode</h3>
|
||||
<p class="text-xs text-gray-500">
|
||||
Connect the device to an existing WiFi network for internet access (e.g.
|
||||
notifications, remote access). The hotspot AP stays running alongside
|
||||
WiFi client mode.
|
||||
</p>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="wifi_enabled"
|
||||
type="checkbox"
|
||||
bind:checked={config.wifi_enabled}
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
|
||||
/>
|
||||
<label for="wifi_enabled" class="ml-2 block text-sm text-gray-700">
|
||||
Enable WiFi
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">
|
||||
Unchecking stops WiFi without clearing saved credentials.
|
||||
</p>
|
||||
|
||||
{#if wifiStatus && config.wifi_enabled}
|
||||
{#if wifiStatus.state === 'connected'}
|
||||
<p class="text-xs text-green-600">
|
||||
Connected to "{wifiStatus.ssid}" ({wifiStatus.ip})
|
||||
</p>
|
||||
{:else if wifiStatus.state === 'connecting'}
|
||||
<p class="text-xs text-amber-600">Connecting...</p>
|
||||
{:else if wifiStatus.state === 'recovering'}
|
||||
<p class="text-xs text-amber-600">Recovering connection...</p>
|
||||
{:else if wifiStatus.state === 'dataPathDead'}
|
||||
<p class="text-xs text-amber-600">
|
||||
Data path stalled, attempting recovery...
|
||||
</p>
|
||||
{:else if wifiStatus.state === 'failed'}
|
||||
<p class="text-xs text-red-600">
|
||||
Failed: {wifiStatus.error}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="wifi_ssid"
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
WiFi Network Name (SSID)
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
id="wifi_ssid"
|
||||
type="text"
|
||||
bind:value={config.wifi_ssid}
|
||||
placeholder="MyWiFiNetwork"
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={do_scan}
|
||||
disabled={scanning}
|
||||
class="px-3 py-2 text-sm bg-gray-100 hover:bg-gray-200 disabled:opacity-50 border border-gray-300 rounded-md"
|
||||
>
|
||||
{scanning ? 'Scanning...' : 'Scan'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if scanError}
|
||||
<p class="text-xs text-red-600">{scanError}</p>
|
||||
{/if}
|
||||
|
||||
{#if scanResults.length > 0}
|
||||
<div
|
||||
class="border border-gray-200 rounded-md max-h-40 overflow-y-auto divide-y divide-gray-200"
|
||||
>
|
||||
{#each scanResults as network}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-2 text-left text-sm hover:bg-gray-50 flex justify-between"
|
||||
onclick={() => select_network(network)}
|
||||
>
|
||||
<span>{network.ssid}</span>
|
||||
<span class="text-gray-400"
|
||||
>{network.signal_dbm} dBm · {network.security}</span
|
||||
>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if config.wifi_ssid}
|
||||
<div>
|
||||
<label
|
||||
for="wifi_security"
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Security Type
|
||||
</label>
|
||||
<select
|
||||
id="wifi_security"
|
||||
bind:value={config.wifi_security}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
|
||||
>
|
||||
<option value="wpa_psk">WPA2 (WPA-PSK)</option>
|
||||
<option value="sae">WPA3 (SAE)</option>
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="wifi_password"
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
WiFi Password
|
||||
</label>
|
||||
<input
|
||||
id="wifi_password"
|
||||
type="password"
|
||||
bind:value={config.wifi_password}
|
||||
placeholder="Enter password"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Changing the network requires re-entering the password.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if config.wifi_ssid}
|
||||
<div>
|
||||
<label
|
||||
for="dns_servers"
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
DNS Servers
|
||||
</label>
|
||||
<input
|
||||
id="dns_servers"
|
||||
type="text"
|
||||
bind:value={dnsServersInput}
|
||||
placeholder="9.9.9.9, 149.112.112.112"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Comma-separated. Used when WiFi is active. Defaults to 9.9.9.9,
|
||||
149.112.112.112 (Quad9).
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="border-t border-gray-200 pt-4 mt-6">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">
|
||||
Analyzer Heuristic Settings
|
||||
</h3>
|
||||
@@ -135,7 +666,7 @@
|
||||
id="imsi_requested"
|
||||
type="checkbox"
|
||||
bind:checked={config.analyzers.imsi_requested}
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
|
||||
/>
|
||||
<label for="imsi_requested" class="ml-2 block text-sm text-gray-700">
|
||||
IMSI Requested Heuristic
|
||||
@@ -147,7 +678,7 @@
|
||||
id="connection_redirect_2g_downgrade"
|
||||
type="checkbox"
|
||||
bind:checked={config.analyzers.connection_redirect_2g_downgrade}
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
|
||||
/>
|
||||
<label
|
||||
for="connection_redirect_2g_downgrade"
|
||||
@@ -162,7 +693,7 @@
|
||||
id="lte_sib6_and_7_downgrade"
|
||||
type="checkbox"
|
||||
bind:checked={config.analyzers.lte_sib6_and_7_downgrade}
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
|
||||
/>
|
||||
<label
|
||||
for="lte_sib6_and_7_downgrade"
|
||||
@@ -177,7 +708,7 @@
|
||||
id="null_cipher"
|
||||
type="checkbox"
|
||||
bind:checked={config.analyzers.null_cipher}
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
|
||||
/>
|
||||
<label for="null_cipher" class="ml-2 block text-sm text-gray-700">
|
||||
Null Cipher Heuristic
|
||||
@@ -189,7 +720,7 @@
|
||||
id="nas_null_cipher"
|
||||
type="checkbox"
|
||||
bind:checked={config.analyzers.nas_null_cipher}
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
|
||||
/>
|
||||
<label for="nas_null_cipher" class="ml-2 block text-sm text-gray-700">
|
||||
NAS Null Cipher Heuristic
|
||||
@@ -201,15 +732,109 @@
|
||||
id="incomplete_sib"
|
||||
type="checkbox"
|
||||
bind:checked={config.analyzers.incomplete_sib}
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
|
||||
/>
|
||||
<label for="nas_null_cipher" class="ml-2 block text-sm text-gray-700">
|
||||
<label for="incomplete_sib" class="ml-2 block text-sm text-gray-700">
|
||||
Incomplete SIB Heuristic
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="test_analyzer"
|
||||
type="checkbox"
|
||||
bind:checked={config.analyzers.test_analyzer}
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
|
||||
/>
|
||||
<label for="test_analyzer" class="ml-2 block text-sm text-gray-700">
|
||||
Test Heuristic (noisy!)
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="diagnostic_analyzer"
|
||||
type="checkbox"
|
||||
bind:checked={config.analyzers.diagnostic_analyzer}
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
|
||||
/>
|
||||
<label
|
||||
for="diagnostic_analyzer"
|
||||
class="ml-2 block text-sm text-gray-700"
|
||||
>
|
||||
Diagnostic Analyzer
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 pt-4 mt-6 space-y-3">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">GPS Settings</h3>
|
||||
<div>
|
||||
<label for="gps_mode" class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>GPS Mode</label
|
||||
>
|
||||
<select
|
||||
id="gps_mode"
|
||||
bind:value={config.gps_mode}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
|
||||
>
|
||||
<option value={GpsMode.Disabled}>Disabled</option>
|
||||
<option value={GpsMode.Fixed}>Fixed coordinates</option>
|
||||
<option value={GpsMode.Api}>API endpoint</option>
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
{#if config.gps_mode === GpsMode.Api}
|
||||
POST latitude and longitude to <code>/api/gps</code> from any device on
|
||||
the network. Timestamp is derived from packet capture timing.
|
||||
{:else if config.gps_mode === GpsMode.Fixed}
|
||||
GPS coordinates are fixed to the values below.
|
||||
{:else}
|
||||
GPS is disabled; no coordinates will be tracked.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{#if config.gps_mode === GpsMode.Fixed}
|
||||
<div>
|
||||
<label
|
||||
for="gps_fixed_latitude"
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>Fixed Latitude</label
|
||||
>
|
||||
<input
|
||||
id="gps_fixed_latitude"
|
||||
type="number"
|
||||
min="-90"
|
||||
max="90"
|
||||
step="any"
|
||||
required
|
||||
bind:value={config.gps_fixed_latitude}
|
||||
placeholder="e.g. 37.7749"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">Decimal degrees, -90 to 90</p>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="gps_fixed_longitude"
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>Fixed Longitude</label
|
||||
>
|
||||
<input
|
||||
id="gps_fixed_longitude"
|
||||
type="number"
|
||||
min="-180"
|
||||
max="180"
|
||||
step="any"
|
||||
required
|
||||
bind:value={config.gps_fixed_longitude}
|
||||
placeholder="e.g. -122.4194"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">Decimal degrees, -180 to 180</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
@@ -242,7 +867,7 @@
|
||||
</form>
|
||||
{#if message}
|
||||
<div
|
||||
class="mt-4 p-3 rounded {messageType === 'error'
|
||||
class="mt-4 p-3 rounded-sm {messageType === 'error'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-green-100 text-green-700'}"
|
||||
>
|
||||
@@ -254,5 +879,5 @@
|
||||
Failed to load configuration. Please try reloading the page.
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
<div class="flex flex-row justify-end gap-2">
|
||||
<DeleteButton
|
||||
text="Delete ALL Recordings"
|
||||
prompt={`Are you sure you want to delete ALL recordings?`}
|
||||
url={`/api/delete-all-recordings`}
|
||||
prompt="Are you sure you want to delete ALL recordings?"
|
||||
url="/api/delete-all-recordings"
|
||||
name="all recodings"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { req } from '$lib/utils.svelte';
|
||||
import { user_action_req } from '$lib/utils.svelte';
|
||||
let {
|
||||
text,
|
||||
url,
|
||||
prompt,
|
||||
name,
|
||||
}: {
|
||||
text?: string;
|
||||
url: string;
|
||||
prompt: string;
|
||||
name: string;
|
||||
} = $props();
|
||||
|
||||
function confirmDelete() {
|
||||
function confirm_delete() {
|
||||
if (window.confirm(prompt)) {
|
||||
req('POST', url);
|
||||
user_action_req('POST', url, 'Unable to delete recording ' + name);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded-md flex flex-row"
|
||||
onclick={confirmDelete}
|
||||
class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-2 sm:px-4 rounded-md flex flex-row"
|
||||
onclick={confirm_delete}
|
||||
aria-label="delete"
|
||||
>
|
||||
<p>{text}</p>
|
||||
|
||||
@@ -8,20 +8,16 @@
|
||||
text: string;
|
||||
full_button?: boolean;
|
||||
} = $props();
|
||||
|
||||
function download() {
|
||||
window.location.href = url;
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
<a
|
||||
href={url}
|
||||
class="flex flex-row {full_button
|
||||
? 'bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-md'
|
||||
? 'bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-2 sm:px-4 rounded-md'
|
||||
: 'text-blue-600 underline'}"
|
||||
onclick={download}
|
||||
>
|
||||
{text}
|
||||
<svg class="fill-current w-4 h-4 m-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
<path d="M13 8V2H7v6H2l8 8 8-8h-5zM0 18h20v2H0v-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</a>
|
||||
|
||||
82
daemon/web/src/lib/components/ExpandableInput.svelte
Normal file
82
daemon/web/src/lib/components/ExpandableInput.svelte
Normal file
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
value = $bindable(''),
|
||||
checkboxId,
|
||||
inputId,
|
||||
label,
|
||||
inputLabel,
|
||||
inputPlaceholder = '',
|
||||
inputHelp = '',
|
||||
children,
|
||||
}: {
|
||||
value: string | null;
|
||||
checkboxId: string;
|
||||
inputId: string;
|
||||
label: string;
|
||||
inputLabel: string;
|
||||
inputPlaceholder?: string;
|
||||
inputHelp?: string;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
|
||||
function has_value(text: string | null) {
|
||||
return text !== null && text.trim() !== '';
|
||||
}
|
||||
|
||||
let expanded = $state(has_value(value));
|
||||
let inputElement = $state<HTMLInputElement | null>(null);
|
||||
|
||||
function handle_checkbox_change(e: Event) {
|
||||
expanded = (e.currentTarget as HTMLInputElement).checked;
|
||||
if (expanded) {
|
||||
setTimeout(() => inputElement?.focus(), 0);
|
||||
} else {
|
||||
value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function handle_input_blur() {
|
||||
if (!has_value(value)) {
|
||||
expanded = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id={checkboxId}
|
||||
type="checkbox"
|
||||
checked={expanded}
|
||||
onchange={handle_checkbox_change}
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
|
||||
/>
|
||||
<label for={checkboxId} class="ml-2 block text-sm text-gray-700">
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if expanded}
|
||||
<div>
|
||||
<label for={inputId} class="block text-sm font-medium text-gray-700 mb-1">
|
||||
{inputLabel}
|
||||
</label>
|
||||
<input
|
||||
id={inputId}
|
||||
type="text"
|
||||
bind:this={inputElement}
|
||||
bind:value
|
||||
onblur={handle_input_blur}
|
||||
placeholder={inputPlaceholder}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
|
||||
/>
|
||||
{#if inputHelp}
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
{inputHelp}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
28
daemon/web/src/lib/components/LogView.svelte
Normal file
28
daemon/web/src/lib/components/LogView.svelte
Normal file
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { get_logs } from '$lib/utils.svelte';
|
||||
import Modal from './Modal.svelte';
|
||||
|
||||
let { shown = $bindable() }: { shown: boolean } = $props();
|
||||
let content: string | undefined = $state(undefined);
|
||||
|
||||
$effect(() => {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
if (content !== undefined && (document.hidden || !shown)) {
|
||||
return;
|
||||
}
|
||||
content = await get_logs();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal bind:shown title="Logs">
|
||||
<div class="bg-gray-100 border border-gray-100 rounded-md overflow-scroll">
|
||||
<pre class="m-2">{content}</pre>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { ManifestEntry } from '$lib/manifest.svelte';
|
||||
import { AnalysisManager } from '$lib/analysisManager.svelte';
|
||||
import DownloadLink from '$lib/components/DownloadLink.svelte';
|
||||
import DeleteButton from '$lib/components/DeleteButton.svelte';
|
||||
import AnalysisStatus from './AnalysisStatus.svelte';
|
||||
@@ -9,10 +10,12 @@
|
||||
entry,
|
||||
current,
|
||||
server_is_recording,
|
||||
manager,
|
||||
}: {
|
||||
entry: ManifestEntry;
|
||||
current: boolean;
|
||||
server_is_recording: boolean;
|
||||
manager: AnalysisManager;
|
||||
} = $props();
|
||||
|
||||
// passing `undefined` as the locale uses the browser default
|
||||
@@ -41,7 +44,7 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="{status_row_color} {status_border_color} drop-shadow p-4 flex flex-col gap-2 border rounded-md flex-1"
|
||||
class="{status_row_color} {status_border_color} drop-shadow-sm p-4 flex flex-col gap-2 border rounded-md flex-1 overflow-x-auto overflow-y-hidden"
|
||||
>
|
||||
{#if current}
|
||||
<div class="flex flex-row justify-between gap-2">
|
||||
@@ -78,7 +81,12 @@
|
||||
'N/A'}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-row justify-between lg:justify-end gap-2 mt-2">
|
||||
{#if entry.stop_reason}
|
||||
<div class="bg-yellow-50 border border-yellow-300 rounded-sm p-2 text-yellow-800 text-sm">
|
||||
{entry.stop_reason}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-row justify-between lg:justify-end gap-1 mt-2 overflow-x-auto">
|
||||
<DownloadLink url={entry.get_pcap_url()} text="pcap" full_button />
|
||||
<DownloadLink url={entry.get_qmdl_url()} text="qmdl" full_button />
|
||||
<DownloadLink url={entry.get_zip_url()} text="zip" full_button />
|
||||
@@ -88,10 +96,11 @@
|
||||
<DeleteButton
|
||||
prompt={`Are you sure you want to delete entry ${entry.name}?`}
|
||||
url={entry.get_delete_url()}
|
||||
name={entry.name}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="border-b {analysis_visible ? '' : 'hidden'}">
|
||||
<AnalysisView {entry} />
|
||||
<div class="border-b border-gray-200 {analysis_visible ? '' : 'hidden'}">
|
||||
<AnalysisView {entry} {manager} {current} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,38 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { ManifestEntry } from '$lib/manifest.svelte';
|
||||
import { AnalysisManager } from '$lib/analysisManager.svelte';
|
||||
import { screenIsLgUp } from '$lib/stores/breakpoint';
|
||||
import TableRow from './ManifestTableRow.svelte';
|
||||
import Card from './ManifestCard.svelte';
|
||||
interface Props {
|
||||
entries: ManifestEntry[];
|
||||
server_is_recording: boolean;
|
||||
manager: AnalysisManager;
|
||||
}
|
||||
let { entries, server_is_recording }: Props = $props();
|
||||
let { entries, server_is_recording, manager }: Props = $props();
|
||||
</script>
|
||||
|
||||
<!--For larger screens we use a table-->
|
||||
<table class="hidden table-auto text-left lg:table">
|
||||
<thead>
|
||||
<tr class="bg-gray-100 drop-shadow">
|
||||
<th class="p-2" scope="col">ID</th>
|
||||
<th class="p-2" scope="col">Started</th>
|
||||
<th class="p-2" scope="col">Last Message</th>
|
||||
<th class="p-2" scope="col">Size</th>
|
||||
<th class="p-2" scope="col">PCAP</th>
|
||||
<th class="p-2" scope="col">QMDL</th>
|
||||
<th class="p-2" scope="col">ZIP</th>
|
||||
<th class="p-2" scope="col">Analysis</th>
|
||||
<th class="p-2" scope="col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each entries as entry, i}
|
||||
<TableRow {entry} current={false} {i} />
|
||||
{#if $screenIsLgUp}
|
||||
<table class="table-auto text-left table">
|
||||
<thead>
|
||||
<tr class="bg-gray-100 drop-shadow-sm">
|
||||
<th class="p-2" scope="col">ID</th>
|
||||
<th class="p-2" scope="col">Started</th>
|
||||
<th class="p-2" scope="col">Last Message</th>
|
||||
<th class="p-2" scope="col">Size</th>
|
||||
<th class="p-2" scope="col">Download</th>
|
||||
<th class="p-2" scope="col">Analysis</th>
|
||||
<th class="p-2" scope="col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each entries as entry, i}
|
||||
<TableRow {entry} current={false} {i} {manager} />
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{:else}
|
||||
<!--For smaller screens we use cards-->
|
||||
<div class="flex flex-col gap-4">
|
||||
{#each entries as entry}
|
||||
<Card {entry} current={false} {server_is_recording} {manager} />
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<!--For smaller screens we use cards-->
|
||||
<div class="lg:hidden flex flex-col gap-4">
|
||||
{#each entries as entry}
|
||||
<Card {entry} current={false} {server_is_recording} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { ManifestEntry } from '$lib/manifest.svelte';
|
||||
import { AnalysisManager } from '$lib/analysisManager.svelte';
|
||||
import DownloadLink from '$lib/components/DownloadLink.svelte';
|
||||
import DeleteButton from '$lib/components/DeleteButton.svelte';
|
||||
import AnalysisStatus from './AnalysisStatus.svelte';
|
||||
@@ -8,10 +9,12 @@
|
||||
entry,
|
||||
current,
|
||||
i,
|
||||
manager,
|
||||
}: {
|
||||
entry: ManifestEntry;
|
||||
current: boolean;
|
||||
i: number;
|
||||
manager: AnalysisManager;
|
||||
} = $props();
|
||||
|
||||
// passing `undefined` as the locale uses the browser default
|
||||
@@ -33,16 +36,20 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<tr class="{status_row_color} drop-shadow">
|
||||
<tr class="{status_row_color} drop-shadow-sm">
|
||||
<td class="p-2">{entry.name}</td>
|
||||
<td class="p-2">{date_formatter.format(entry.start_time)}</td>
|
||||
<td class="p-2"
|
||||
>{(entry.last_message_time && date_formatter.format(entry.last_message_time)) || 'N/A'}</td
|
||||
>
|
||||
<td class="p-2">{entry.get_readable_qmdl_size()}</td>
|
||||
<td class="p-2"><DownloadLink url={entry.get_pcap_url()} text="pcap" /></td>
|
||||
<td class="p-2"><DownloadLink url={entry.get_qmdl_url()} text="qmdl" /></td>
|
||||
<td class="p-2"><DownloadLink url={entry.get_zip_url()} text="zip" /></td>
|
||||
<td class="p-2">
|
||||
<div class="flex flex-row gap-2">
|
||||
<DownloadLink url={entry.get_pcap_url()} text="pcap" />
|
||||
<DownloadLink url={entry.get_qmdl_url()} text="qmdl" />
|
||||
<DownloadLink url={entry.get_zip_url()} text="zip" />
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-2"
|
||||
><AnalysisStatus onclick={toggle_analysis_visibility} {entry} {analysis_visible} /></td
|
||||
>
|
||||
@@ -53,12 +60,13 @@
|
||||
<DeleteButton
|
||||
prompt={`Are you sure you want to delete entry ${entry.name}?`}
|
||||
url={entry.get_delete_url()}
|
||||
name={entry.name}
|
||||
/>
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
<tr class="{alternating_row_color} border-b {analysis_visible ? '' : 'hidden'}">
|
||||
<td class="border-t border-dashed p-2" colspan="9">
|
||||
<AnalysisView {entry} />
|
||||
<tr class="{alternating_row_color} border-b border-gray-200 {analysis_visible ? '' : 'hidden'}">
|
||||
<td class="border-t border-gray-200 border-dashed p-2" colspan="9">
|
||||
<AnalysisView {entry} {manager} {current} />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
64
daemon/web/src/lib/components/Modal.svelte
Normal file
64
daemon/web/src/lib/components/Modal.svelte
Normal file
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let {
|
||||
shown = $bindable(),
|
||||
title,
|
||||
children,
|
||||
}: { shown: boolean; title: string; children: Snippet } = $props();
|
||||
|
||||
onMount(() => {
|
||||
const handler = () => {
|
||||
document.documentElement.style.setProperty('--scroll-y', `${window.scrollY}px`);
|
||||
};
|
||||
window.addEventListener('scroll', handler);
|
||||
return () => window.removeEventListener('scroll', handler);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (shown) {
|
||||
const scrollY = document.documentElement.style.getPropertyValue('--scroll-y');
|
||||
const body = document.body;
|
||||
body.style.position = 'fixed';
|
||||
body.style.top = `-${scrollY}`;
|
||||
} else {
|
||||
const body = document.body;
|
||||
const scrollY = body.style.top;
|
||||
body.style.position = '';
|
||||
body.style.top = '';
|
||||
window.scrollTo(0, parseInt(scrollY || '0') * -1);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if shown}
|
||||
<div
|
||||
class="fixed left-5 right-5 top-5 bottom-5 z-50 bg-white border border-white rounded-md
|
||||
flex flex-col p-2 drop-shadow-sm"
|
||||
>
|
||||
<div class="flex justify-between items-center p-1">
|
||||
<span class="text-2xl">{title}</span>
|
||||
<button onclick={() => (shown = false)} aria-label="close">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M5.29289 5.29289C5.68342 4.90237 6.31658 4.90237 6.70711 5.29289L12 10.5858L17.2929 5.29289C17.6834 4.90237 18.3166 4.90237 18.7071 5.29289C19.0976 5.68342 19.0976 6.31658 18.7071 6.70711L13.4142 12L18.7071 17.2929C19.0976 17.6834 19.0976 18.3166 18.7071 18.7071C18.3166 19.0976 17.6834 19.0976 17.2929 18.7071L12 13.4142L6.70711 18.7071C6.31658 19.0976 5.68342 19.0976 5.29289 18.7071C4.90237 18.3166 4.90237 17.6834 5.29289 17.2929L10.5858 12L5.29289 6.70711C4.90237 6.31658 4.90237 5.68342 5.29289 5.29289Z"
|
||||
fill="#0F1729"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
48
daemon/web/src/lib/components/ReAnalyzeButton.svelte
Normal file
48
daemon/web/src/lib/components/ReAnalyzeButton.svelte
Normal file
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import ApiRequestButton from './ApiRequestButton.svelte';
|
||||
import { AnalysisStatus, AnalysisManager } from '$lib/analysisManager.svelte';
|
||||
import type { ManifestEntry } from '$lib/manifest.svelte';
|
||||
|
||||
let {
|
||||
entry,
|
||||
manager,
|
||||
}: {
|
||||
entry: ManifestEntry;
|
||||
manager: AnalysisManager;
|
||||
} = $props();
|
||||
|
||||
let url = $derived(entry.get_reanalyze_url());
|
||||
let entry_name = $derived(entry.name);
|
||||
let analysis_status = $derived(entry.analysis_status);
|
||||
|
||||
let is_processing = $derived(
|
||||
analysis_status === AnalysisStatus.Queued || analysis_status === AnalysisStatus.Running
|
||||
);
|
||||
|
||||
async function handle_re_analyze() {
|
||||
// Update the entry directly for immediate UI feedback
|
||||
entry.analysis_status = AnalysisStatus.Queued;
|
||||
entry.analysis_report = undefined;
|
||||
manager.set_queued_status(entry_name);
|
||||
}
|
||||
</script>
|
||||
|
||||
<ApiRequestButton
|
||||
{url}
|
||||
label="Re-analyze"
|
||||
loadingLabel="Analyzing..."
|
||||
disabled={is_processing}
|
||||
variant="blue"
|
||||
onclick={handle_re_analyze}
|
||||
ariaLabel="re-analyze"
|
||||
errorMessage="Error re-analyzing recoding"
|
||||
>
|
||||
{#snippet icon()}
|
||||
<svg style="width:20px;height:20px" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="white"
|
||||
d="M12,18A6,6 0 0,1 6,12C6,11 6.25,10.03 6.7,9.2L5.24,7.74C4.46,8.97 4,10.43 4,12A8,8 0 0,0 12,20V23L16,19L12,15M12,4V1L8,5L12,9V6A6,6 0 0,1 18,12C18,13 17.75,13.97 17.3,14.8L18.76,16.26C19.54,15.03 20,13.57 20,12A8,8 0 0,0 12,4Z"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
</ApiRequestButton>
|
||||
@@ -1,100 +1,60 @@
|
||||
<script lang="ts">
|
||||
import { req } from '$lib/utils.svelte';
|
||||
import ApiRequestButton from './ApiRequestButton.svelte';
|
||||
let {
|
||||
server_is_recording,
|
||||
}: {
|
||||
server_is_recording: boolean;
|
||||
} = $props();
|
||||
|
||||
let client_set_recording = $state(server_is_recording);
|
||||
let waiting_for_server = $derived(client_set_recording !== server_is_recording);
|
||||
|
||||
async function start_recording() {
|
||||
await req('POST', '/api/start-recording');
|
||||
client_set_recording = true;
|
||||
}
|
||||
|
||||
async function stop_recording() {
|
||||
await req('POST', '/api/stop-recording');
|
||||
client_set_recording = false;
|
||||
}
|
||||
|
||||
const recording_button_classes =
|
||||
'text-white font-bold py-2 px-4 rounded-md flex flex-row gap-1';
|
||||
const stop_recording_classes = `${recording_button_classes} bg-red-500 opacity-50 cursor-not-allowed`;
|
||||
const start_recording_classes = `${recording_button_classes} bg-blue-500 opacity-50 cursor-not-allowed`;
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{#if waiting_for_server}
|
||||
<button
|
||||
class={server_is_recording ? stop_recording_classes : start_recording_classes}
|
||||
disabled
|
||||
{#if server_is_recording}
|
||||
<ApiRequestButton
|
||||
url="/api/stop-recording"
|
||||
label="Stop"
|
||||
variant="red"
|
||||
errorMessage="Error stoppping recording"
|
||||
>
|
||||
<span>{server_is_recording ? 'Stopping...' : 'Starting...'}</span>
|
||||
<svg
|
||||
class="w-4 h-4 text-white animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
{#snippet icon()}
|
||||
<svg
|
||||
class="w-6 h-6 text-white"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="currentColor"
|
||||
d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
{:else if server_is_recording}
|
||||
<button
|
||||
class="{recording_button_classes} bg-red-500 hover:bg-red-700"
|
||||
onclick={stop_recording}
|
||||
>
|
||||
<span>Stop</span>
|
||||
<svg
|
||||
class="w-6 h-6 text-white"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M7 5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H7Z" />
|
||||
</svg>
|
||||
</button>
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M7 5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H7Z"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
</ApiRequestButton>
|
||||
{:else}
|
||||
<button
|
||||
class="{recording_button_classes} bg-blue-500 hover:bg-blue-700"
|
||||
onclick={start_recording}
|
||||
<ApiRequestButton
|
||||
url="/api/start-recording"
|
||||
label="Start"
|
||||
variant="blue"
|
||||
errorMessage="Error starting recording"
|
||||
>
|
||||
<span>Start</span>
|
||||
<svg
|
||||
class="w-6 h-6 text-white"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8.6 5.2A1 1 0 0 0 7 6v12a1 1 0 0 0 1.6.8l8-6a1 1 0 0 0 0-1.6l-8-6Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{#snippet icon()}
|
||||
<svg
|
||||
class="w-6 h-6 text-white"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8.6 5.2A1 1 0 0 0 7 6v12a1 1 0 0 0 1.6.8l8-6a1 1 0 0 0 0-1.6l-8-6Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
</ApiRequestButton>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
|
||||
@@ -1,37 +1,140 @@
|
||||
<script lang="ts">
|
||||
import { type SystemStats } from '$lib/systemStats';
|
||||
import { gps_mode_label, GpsMode, type GpsData } from '$lib/utils.svelte';
|
||||
let {
|
||||
stats,
|
||||
gps_data = null,
|
||||
gps_mode = GpsMode.Disabled,
|
||||
}: {
|
||||
stats: SystemStats;
|
||||
gps_data?: GpsData | null;
|
||||
gps_mode?: GpsMode;
|
||||
} = $props();
|
||||
|
||||
const table_cell_classes = 'border p-1 lg:p-2';
|
||||
let battery_level = $derived(stats.battery_status ? stats.battery_status.level : 0);
|
||||
let bar_color = $derived.by(() => {
|
||||
if (stats.battery_status === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (battery_level <= 10) {
|
||||
return 'fill-red-500';
|
||||
}
|
||||
if (battery_level <= 25) {
|
||||
return 'fill-yellow-300';
|
||||
}
|
||||
return 'fill-green-500';
|
||||
});
|
||||
let title_text = $derived.by(() => {
|
||||
if (stats.battery_status === undefined) {
|
||||
return 'Rayhunter does not yet support displaying the battery level for this device.';
|
||||
}
|
||||
|
||||
let text = `Battery is ${stats.battery_status.level}% full`;
|
||||
|
||||
if (stats.battery_status.is_plugged_in) {
|
||||
text += ' and plugged in';
|
||||
}
|
||||
return text;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex-1 drop-shadow p-4 flex flex-col gap-2 border rounded-md bg-gray-100 border-gray-100"
|
||||
class="flex-1 drop-shadow-sm p-4 flex flex-col gap-2 border rounded-md bg-gray-100 border-gray-100"
|
||||
>
|
||||
<p class="text-xl mb-2">System Information</p>
|
||||
<table class="table-auto border">
|
||||
<table class="text-sm w-full">
|
||||
<tbody>
|
||||
<tr class="border">
|
||||
<th class={table_cell_classes}> Rayhunter Version </th>
|
||||
<td class={table_cell_classes}>{stats.runtime_metadata.rayhunter_version}</td>
|
||||
<tr class="border-b border-gray-200">
|
||||
<td class="py-1 pr-4 text-gray-500 font-medium">Rayhunter Version</td>
|
||||
<td class="py-1">{stats.runtime_metadata.rayhunter_version}</td>
|
||||
</tr>
|
||||
<tr class="border">
|
||||
<th class={table_cell_classes}> Storage </th>
|
||||
<td class={table_cell_classes}>
|
||||
<tr class="border-b border-gray-200">
|
||||
<td class="py-1 pr-4 text-gray-500 font-medium">Storage</td>
|
||||
<td class="py-1">
|
||||
{stats.disk_stats.used_percent} used ({stats.disk_stats.used_size} used / {stats
|
||||
.disk_stats.available_size} available)
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b">
|
||||
<th class={table_cell_classes}> Memory (RAM) </th>
|
||||
<td class={table_cell_classes}>
|
||||
<tr class="border-b border-gray-200">
|
||||
<td class="py-1 pr-4 text-gray-500 font-medium">Memory (RAM)</td>
|
||||
<td class="py-1">
|
||||
Free: {stats.memory_stats.free}, Used: {stats.memory_stats.used}
|
||||
</td>
|
||||
</tr>
|
||||
<tr class={gps_mode !== GpsMode.Disabled ? 'border-b border-gray-200' : ''}>
|
||||
<td class="py-1 pr-4 text-gray-500 font-medium">Battery</td>
|
||||
<td class="py-1">
|
||||
<svg
|
||||
width="80"
|
||||
height="30"
|
||||
viewBox="0 0 80 30"
|
||||
role="img"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="battery-icon"
|
||||
>
|
||||
<title>{title_text}</title>
|
||||
<rect
|
||||
class="fill-none stroke-neutral-800 stroke-2"
|
||||
width="70"
|
||||
height="30"
|
||||
rx="3"
|
||||
ry="3"
|
||||
/>
|
||||
<rect
|
||||
class="fill-neutral-800"
|
||||
x="70"
|
||||
y="7"
|
||||
width="8"
|
||||
height="16"
|
||||
rx="2"
|
||||
ry="2"
|
||||
/>
|
||||
<rect
|
||||
class={bar_color}
|
||||
x="2"
|
||||
y="2"
|
||||
height="26"
|
||||
rx="2"
|
||||
ry="2"
|
||||
style="width: {battery_level * 0.66}px;"
|
||||
/>
|
||||
{#if stats.battery_status && stats.battery_status.is_plugged_in}
|
||||
<path
|
||||
class="fill-yellow-300 stroke-neutral-800 stroke-1"
|
||||
d="M38 3 L28 17 L34 17 L30 27 L40 13 L34 13 Z"
|
||||
/>
|
||||
{/if}
|
||||
{#if !stats.battery_status}
|
||||
<text
|
||||
class="fill-neutral-500 text-[20px] font-bold [text-anchor:middle] [dominant-baseline:central]"
|
||||
x="35"
|
||||
y="15">?</text
|
||||
>
|
||||
{/if}
|
||||
</svg>
|
||||
</td>
|
||||
</tr>
|
||||
{#if gps_mode !== GpsMode.Disabled}
|
||||
<tr class="border-b border-gray-200">
|
||||
<td class="py-1 pr-4 text-gray-500 font-medium">GPS Mode</td>
|
||||
<td class="py-1">{gps_mode_label(gps_mode)}</td>
|
||||
</tr>
|
||||
{#if gps_data}
|
||||
<tr class="border-b border-gray-200">
|
||||
<td class="py-1 pr-4 text-gray-500 font-medium">Latitude</td>
|
||||
<td class="py-1 font-mono">{gps_data.latitude.toFixed(6)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-1 pr-4 text-gray-500 font-medium">Longitude</td>
|
||||
<td class="py-1 font-mono">{gps_data.longitude.toFixed(6)}</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr>
|
||||
<td class="py-1 pr-4 text-gray-500 font-medium">GPS Data</td>
|
||||
<td class="py-1 text-gray-400">Awaiting GPS data...</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
50
daemon/web/src/lib/components/UpdateNotice.svelte
Normal file
50
daemon/web/src/lib/components/UpdateNotice.svelte
Normal file
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import type { UpdateStatus } from '$lib/utils.svelte';
|
||||
|
||||
let { status = null }: { status: UpdateStatus | null } = $props();
|
||||
|
||||
let is_visible = $derived(
|
||||
Boolean(status?.update_available && status.latest_version && status.latest_release_url)
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if is_visible && status}
|
||||
<div class="bg-sky-100 border-sky-300 drop-shadow-sm p-4 flex flex-col gap-2 border rounded-md">
|
||||
<span class="text-xl font-bold flex flex-row items-center gap-2 text-sky-800">
|
||||
<svg
|
||||
class="w-6 h-6 text-sky-700"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm11-4a1 1 0 1 0-2 0v5a1 1 0 1 0 2 0V8Zm-1 7a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H12Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Software Update Available
|
||||
</span>
|
||||
<p>
|
||||
A new version of Rayhunter is available! You are currently running version {status.current_version},
|
||||
and the latest release is version {status.latest_version}.
|
||||
</p>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span class="text-sm text-sky-900/80">
|
||||
View the latest release on GitHub to see what's new and download the update.
|
||||
</span>
|
||||
<a
|
||||
class="inline-flex items-center justify-center rounded-md bg-sky-700 px-4 py-2 text-white font-semibold hover:bg-sky-800"
|
||||
href={status.latest_release_url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
aria-label="View latest release on GitHub"
|
||||
>
|
||||
View Release
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { get_report, type AnalysisReport } from './analysis.svelte';
|
||||
import { AnalysisStatus, type AnalysisManager } from './analysisManager.svelte';
|
||||
import { GpsMode } from './utils.svelte';
|
||||
|
||||
interface JsonManifest {
|
||||
entries: JsonManifestEntry[];
|
||||
@@ -11,6 +12,9 @@ interface JsonManifestEntry {
|
||||
start_time: string;
|
||||
last_message_time: string;
|
||||
qmdl_size_bytes: number;
|
||||
stop_reason: string | null;
|
||||
upload_time: string | null;
|
||||
gps_mode: GpsMode | null;
|
||||
}
|
||||
|
||||
export class Manifest {
|
||||
@@ -57,6 +61,9 @@ export class ManifestEntry {
|
||||
public analysis_size_bytes = $state(0);
|
||||
public analysis_status: AnalysisStatus | undefined = $state(undefined);
|
||||
public analysis_report: AnalysisReport | string | undefined = $state(undefined);
|
||||
public stop_reason: string | undefined = $state(undefined);
|
||||
public upload_time: Date | undefined = $state(undefined);
|
||||
public gps_mode: GpsMode | undefined = $state(undefined);
|
||||
|
||||
constructor(json: JsonManifestEntry) {
|
||||
this.name = json.name;
|
||||
@@ -65,6 +72,15 @@ export class ManifestEntry {
|
||||
if (json.last_message_time) {
|
||||
this.last_message_time = new Date(json.last_message_time);
|
||||
}
|
||||
if (json.stop_reason) {
|
||||
this.stop_reason = json.stop_reason;
|
||||
}
|
||||
if (json.upload_time) {
|
||||
this.upload_time = new Date(json.upload_time);
|
||||
}
|
||||
if (json.gps_mode !== null) {
|
||||
this.gps_mode = json.gps_mode;
|
||||
}
|
||||
}
|
||||
|
||||
get_readable_qmdl_size(): string {
|
||||
@@ -102,4 +118,8 @@ export class ManifestEntry {
|
||||
get_delete_url(): string {
|
||||
return `/api/delete-recording/${this.name}`;
|
||||
}
|
||||
|
||||
get_reanalyze_url(): string {
|
||||
return `/api/analysis/${this.name}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,9 @@ export function parse_ndjson(input: string): NewlineDeliminatedJson {
|
||||
// however, if we've reached the end of the input, that means we
|
||||
// were given invalid nd-json
|
||||
if (lines.length === 0) {
|
||||
throw new Error(`unable to parse invalid nd-json: ${e}, "${current_line}"`);
|
||||
throw new Error(`unable to parse invalid nd-json: ${e}, "${current_line}"`, {
|
||||
cause: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
29
daemon/web/src/lib/stores/breakpoint.ts
Normal file
29
daemon/web/src/lib/stores/breakpoint.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// stores/breakpoint.ts
|
||||
import { readable, type Readable } from 'svelte/store';
|
||||
import { breakpoints } from '../../theme';
|
||||
|
||||
type Breakpoint = keyof typeof breakpoints;
|
||||
|
||||
// Store that tracks if a specific breakpoint matches
|
||||
export function create_breakpoint_store(breakpoint: Breakpoint): Readable<boolean> {
|
||||
return readable<boolean>(false, (set) => {
|
||||
const width = breakpoints[breakpoint];
|
||||
const mediaQuery = window.matchMedia(`(min-width: ${width})`);
|
||||
|
||||
// Set initial value
|
||||
set(mediaQuery.matches);
|
||||
|
||||
// Update on change
|
||||
const handler = (e: MediaQueryListEvent) => set(e.matches);
|
||||
mediaQuery.addEventListener('change', handler);
|
||||
|
||||
// Cleanup
|
||||
return () => mediaQuery.removeEventListener('change', handler);
|
||||
});
|
||||
}
|
||||
|
||||
// Create stores for each breakpoint
|
||||
export const screenIsSmUp: Readable<boolean> = create_breakpoint_store('sm');
|
||||
export const screenIsMdUp: Readable<boolean> = create_breakpoint_store('md');
|
||||
export const screenIsLgUp: Readable<boolean> = create_breakpoint_store('lg');
|
||||
export const screenIsXlUp: Readable<boolean> = create_breakpoint_store('xl');
|
||||
@@ -2,6 +2,7 @@ export interface SystemStats {
|
||||
disk_stats: DiskStats;
|
||||
memory_stats: MemoryStats;
|
||||
runtime_metadata: RuntimeMetadata;
|
||||
battery_status?: BatteryStatus;
|
||||
}
|
||||
|
||||
export interface RuntimeMetadata {
|
||||
@@ -17,6 +18,7 @@ export interface DiskStats {
|
||||
available_size: string;
|
||||
used_percent: string;
|
||||
mounted_on: string;
|
||||
available_bytes?: number;
|
||||
}
|
||||
|
||||
export interface MemoryStats {
|
||||
@@ -24,3 +26,8 @@ export interface MemoryStats {
|
||||
used: string;
|
||||
free: string;
|
||||
}
|
||||
|
||||
export interface BatteryStatus {
|
||||
level: number;
|
||||
is_plugged_in: boolean;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { add_error } from './action_errors.svelte';
|
||||
import { Manifest } from './manifest.svelte';
|
||||
import type { SystemStats } from './systemStats';
|
||||
|
||||
@@ -8,24 +9,117 @@ export interface AnalyzerConfig {
|
||||
null_cipher: boolean;
|
||||
nas_null_cipher: boolean;
|
||||
incomplete_sib: boolean;
|
||||
test_analyzer: boolean;
|
||||
diagnostic_analyzer: boolean;
|
||||
}
|
||||
|
||||
export enum enabled_notifications {
|
||||
Warning = 'Warning',
|
||||
LowBattery = 'LowBattery',
|
||||
Update = 'Update',
|
||||
}
|
||||
|
||||
export interface WebdavConfig {
|
||||
url: string;
|
||||
username: string | null;
|
||||
password: string | null;
|
||||
upload_timeout_secs: number;
|
||||
poll_interval_secs: number;
|
||||
min_age_secs: number;
|
||||
delete_on_upload: boolean;
|
||||
}
|
||||
|
||||
export enum GpsMode {
|
||||
Disabled = 0,
|
||||
Fixed = 1,
|
||||
Api = 2,
|
||||
}
|
||||
|
||||
export function gps_mode_label(mode: GpsMode | undefined | null): string {
|
||||
switch (mode) {
|
||||
case GpsMode.Fixed:
|
||||
return 'Fixed coordinates';
|
||||
case GpsMode.Api:
|
||||
return 'API endpoint';
|
||||
default:
|
||||
return 'Disabled';
|
||||
}
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
device: string;
|
||||
ui_level: number;
|
||||
colorblind_mode: boolean;
|
||||
key_input_mode: number;
|
||||
ntfy_url: string | null;
|
||||
enabled_notifications: enabled_notifications[];
|
||||
auto_check_updates: boolean;
|
||||
analyzers: AnalyzerConfig;
|
||||
min_space_to_start_recording_mb: number;
|
||||
min_space_to_continue_recording_mb: number;
|
||||
wifi_ssid: string | null;
|
||||
wifi_password: string | null;
|
||||
wifi_security: 'wpa_psk' | 'sae' | null;
|
||||
wifi_enabled: boolean;
|
||||
dns_servers: string[] | null;
|
||||
firewall_restrict_outbound: boolean;
|
||||
firewall_allowed_ports: number[] | null;
|
||||
webdav: WebdavConfig;
|
||||
gps_mode: GpsMode;
|
||||
gps_fixed_latitude: number | null;
|
||||
gps_fixed_longitude: number | null;
|
||||
}
|
||||
|
||||
export async function req(method: string, url: string): Promise<string> {
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
});
|
||||
const body = await response.text();
|
||||
export interface WifiStatus {
|
||||
state: string;
|
||||
ssid?: string;
|
||||
ip?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface WifiNetwork {
|
||||
ssid: string;
|
||||
signal_dbm: number;
|
||||
security: string;
|
||||
}
|
||||
|
||||
export async function get_wifi_status(): Promise<WifiStatus> {
|
||||
return JSON.parse(await req('GET', '/api/wifi-status'));
|
||||
}
|
||||
|
||||
export async function scan_wifi_networks(): Promise<WifiNetwork[]> {
|
||||
return JSON.parse(await req('POST', '/api/wifi-scan'));
|
||||
}
|
||||
|
||||
export async function req(method: string, url: string, json_body?: unknown): Promise<string> {
|
||||
const options: RequestInit = { method };
|
||||
if (json_body !== undefined) {
|
||||
options.body = JSON.stringify(json_body);
|
||||
options.headers = { 'Content-Type': 'application/json' };
|
||||
}
|
||||
const response = await fetch(url, options);
|
||||
const responseBody = await response.text();
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
return body;
|
||||
return responseBody;
|
||||
} else {
|
||||
throw new Error(body);
|
||||
throw new Error(responseBody);
|
||||
}
|
||||
}
|
||||
|
||||
// A wrapper around req that reports errors to the UI
|
||||
export async function user_action_req(
|
||||
method: string,
|
||||
url: string,
|
||||
error_msg: string,
|
||||
json_body?: unknown
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
return await req(method, url, json_body);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
add_error(error, error_msg);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +132,10 @@ export async function get_system_stats(): Promise<SystemStats> {
|
||||
return JSON.parse(await req('GET', '/api/system-stats'));
|
||||
}
|
||||
|
||||
export async function get_logs(): Promise<string> {
|
||||
return await req('GET', '/api/log');
|
||||
}
|
||||
|
||||
export async function get_config(): Promise<Config> {
|
||||
return JSON.parse(await req('GET', '/api/config'));
|
||||
}
|
||||
@@ -56,3 +154,55 @@ export async function set_config(config: Config): Promise<void> {
|
||||
throw new Error(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function test_notification(): Promise<void> {
|
||||
const response = await fetch('/api/test-notification', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(error);
|
||||
}
|
||||
}
|
||||
|
||||
export interface TimeResponse {
|
||||
system_time: string;
|
||||
adjusted_time: string;
|
||||
offset_seconds: number;
|
||||
}
|
||||
|
||||
export interface UpdateStatus {
|
||||
current_version: string;
|
||||
latest_version?: string | null;
|
||||
latest_release_url?: string | null;
|
||||
update_available: boolean;
|
||||
last_checked?: string | null;
|
||||
last_error?: string | null;
|
||||
}
|
||||
|
||||
export async function get_daemon_time(): Promise<TimeResponse> {
|
||||
return JSON.parse(await req('GET', '/api/time'));
|
||||
}
|
||||
|
||||
export async function get_update_status(): Promise<UpdateStatus> {
|
||||
return JSON.parse(await req('GET', '/api/update-status'));
|
||||
}
|
||||
|
||||
export interface GpsData {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
/** Unix timestamp in seconds (0 = fixed/no real time). */
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export async function get_gps(): Promise<GpsData | null> {
|
||||
const response = await fetch('/api/gps', { cache: 'no-store' });
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
return response.json();
|
||||
}
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
|
||||
@@ -1,40 +1,163 @@
|
||||
<script lang="ts">
|
||||
import { ManifestEntry } from '$lib/manifest.svelte';
|
||||
import { get_manifest, get_system_stats } from '$lib/utils.svelte';
|
||||
import {
|
||||
get_manifest,
|
||||
get_system_stats,
|
||||
get_update_status,
|
||||
get_gps,
|
||||
get_config,
|
||||
GpsMode,
|
||||
type UpdateStatus,
|
||||
type GpsData,
|
||||
} from '$lib/utils.svelte';
|
||||
import ManifestTable from '$lib/components/ManifestTable.svelte';
|
||||
import Card from '$lib/components/ManifestCard.svelte';
|
||||
import type { SystemStats } from '$lib/systemStats';
|
||||
import { AnalysisManager } from '$lib/analysisManager.svelte';
|
||||
import SystemStatsTable from '$lib/components/SystemStatsTable.svelte';
|
||||
import DeleteAllButton from '$lib/components/DeleteAllButton.svelte';
|
||||
import RecordingControls from '$lib/components//RecordingControls.svelte';
|
||||
import RecordingControls from '$lib/components/RecordingControls.svelte';
|
||||
import ConfigForm from '$lib/components/ConfigForm.svelte';
|
||||
import ActionErrors from '$lib/components/ActionErrors.svelte';
|
||||
import ClockDriftAlert from '$lib/components/ClockDriftAlert.svelte';
|
||||
import LogView from '$lib/components/LogView.svelte';
|
||||
import UpdateNotice from '$lib/components/UpdateNotice.svelte';
|
||||
|
||||
let manager: AnalysisManager = new AnalysisManager();
|
||||
let loaded = $state(false);
|
||||
let filter_threshold: boolean = $state(false);
|
||||
let entries: ManifestEntry[] = $state([]);
|
||||
let current_entry: ManifestEntry | undefined = $state(undefined);
|
||||
let system_stats: SystemStats | undefined = $state(undefined);
|
||||
let update_error: string | undefined = $state(undefined);
|
||||
let logview_shown: boolean = $state(false);
|
||||
let config_shown: boolean = $state(false);
|
||||
let gps_data: GpsData | null = $state(null);
|
||||
let gps_mode: GpsMode = $state(GpsMode.Disabled);
|
||||
let update_status: UpdateStatus | null = $state(null);
|
||||
$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;
|
||||
try {
|
||||
// Don't update UI if browser tab isn't visible
|
||||
if (document.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
system_stats = await get_system_stats();
|
||||
loaded = true;
|
||||
await manager.update();
|
||||
let new_manifest = await get_manifest();
|
||||
await new_manifest.set_analysis_status(manager);
|
||||
entries = filter_threshold
|
||||
? new_manifest.entries.filter((e) => e.get_num_warnings())
|
||||
: new_manifest.entries;
|
||||
|
||||
current_entry = new_manifest.current_entry;
|
||||
|
||||
system_stats = await get_system_stats();
|
||||
// Allow update status to fail
|
||||
try {
|
||||
update_status = await get_update_status();
|
||||
} catch (error) {
|
||||
console.error('Error fetching update status:', error);
|
||||
update_status = null;
|
||||
}
|
||||
const config = await get_config();
|
||||
gps_mode = config.gps_mode;
|
||||
gps_data = await get_gps();
|
||||
update_error = undefined;
|
||||
loaded = true;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
update_error = error.message;
|
||||
} else {
|
||||
update_error = '';
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="p-4 xl:px-8 bg-rayhunter-blue drop-shadow flex flex-row justify-between items-center">
|
||||
<LogView bind:shown={logview_shown} />
|
||||
<ConfigForm bind:shown={config_shown} />
|
||||
<div
|
||||
class="p-4 xl:px-8 bg-rayhunter-blue drop-shadow-sm flex flex-row justify-between items-center"
|
||||
>
|
||||
<!-- https://www.w3.org/WAI/tutorials/images/decorative/ -->
|
||||
<img src="/rayhunter_text.png" alt="" class="h-10 xl:h-12" />
|
||||
<div class="flex flex-row gap-4">
|
||||
<button onclick={() => (logview_shown = true)} class="flex flex-row gap-1 group">
|
||||
<span class="hidden text-white group-hover:text-gray-400 lg:flex">Logs</span>
|
||||
<svg
|
||||
class="w-6 h-6 text-white group-hover:text-gray-400"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M10 14H3"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M10 18H3"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M14 15L17.5 18L21 15"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M3 6L13.5 6M20 6L17.75 6"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M20 10L9.5 10M3 10H5.25"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button onclick={() => (config_shown = true)} class="flex flex-row gap-1 group">
|
||||
<span class="hidden text-white group-hover:text-gray-400 lg:flex">Config</span>
|
||||
<svg
|
||||
class="w-6 h-6 text-white group-hover:text-gray-400"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 13v-2a1 1 0 0 0-1-1h-.757l-.707-1.707.535-.536a1 1 0 0 0 0-1.414l-1.414-1.414a1 1 0 0 0-1.414 0l-.536.535L14 5.757V5a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v.757L8.293 6.464l-.536-.535a1 1 0 0 0-1.414 0L4.929 7.343a1 1 0 0 0 0 1.414l.535.536L4.757 11H4a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h.757l.707 1.707-.535.536a1 1 0 0 0 0 1.414l1.414 1.414a1 1 0 0 0 1.414 0l.536-.535L10 18.243V19a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-.757l1.707-.707.536.535a1 1 0 0 0 1.414 0l1.414-1.414a1 1 0 0 0 0-1.414l-.535-.536.707-1.707H20a1 1 0 0 0 1-1Z"
|
||||
/>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="w-px bg-white/30 self-stretch"></div>
|
||||
<a
|
||||
class="flex flex-row gap-1 group"
|
||||
href="https://github.com/EFForg/rayhunter/issues"
|
||||
@@ -81,16 +204,78 @@
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
class="flex flex-row gap-1 group"
|
||||
href="https://supporters.eff.org/donate"
|
||||
target="_blank"
|
||||
>
|
||||
<span class="hidden text-white group-hover:text-gray-400 lg:flex">Donate</span>
|
||||
<svg
|
||||
class="w-6 h-6 text-white group-hover:text-gray-400"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="m12.75 20.66 6.184-7.098c2.677-2.884 2.559-6.506.754-8.705-.898-1.095-2.206-1.816-3.72-1.855-1.293-.034-2.652.43-3.963 1.537-1.31-1.108-2.67-1.571-3.962-1.537-1.515.04-2.823.76-3.72 1.855-1.806 2.2-1.924 5.821.753 8.705l6.184 7.098.245.281a.75.75 0 0 0 1.09 0l.246-.281Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="m-4 xl:mx-8 flex flex-col gap-4">
|
||||
{#if update_error !== undefined}
|
||||
<div
|
||||
class="bg-red-100 border-red-100 drop-shadow-sm p-4 flex flex-col gap-2 border rounded-md flex-1 justify-between"
|
||||
>
|
||||
<span class="text-2xl font-bold mb-2 flex flex-row items-center gap-2 text-red-600">
|
||||
<svg
|
||||
class="w-8 h-8 text-red-600"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm11-4a1 1 0 1 0-2 0v5a1 1 0 1 0 2 0V8Zm-1 7a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H12Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Connection Error
|
||||
</span>
|
||||
<span
|
||||
>This webpage is not currently receiving updates from your Rayhunter device. This
|
||||
could be due to loss of connection or some issue with your device.</span
|
||||
>
|
||||
{#if update_error}
|
||||
<details>
|
||||
<summary>Error</summary>
|
||||
<code>{update_error}</code>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<ActionErrors />
|
||||
<ClockDriftAlert />
|
||||
<UpdateNotice status={update_status} />
|
||||
{#if loaded}
|
||||
<div class="flex flex-col lg:flex-row gap-4">
|
||||
{#if current_entry}
|
||||
<Card entry={current_entry} current={true} server_is_recording={!!current_entry} />
|
||||
<Card
|
||||
entry={current_entry}
|
||||
current={true}
|
||||
server_is_recording={!!current_entry}
|
||||
{manager}
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="bg-red-100 border-red-100 drop-shadow p-4 flex flex-col gap-2 border rounded-md flex-1 justify-between"
|
||||
class="bg-red-100 border-red-100 drop-shadow-sm p-4 flex flex-col gap-2 border rounded-md flex-1 justify-between"
|
||||
>
|
||||
<span
|
||||
class="text-2xl font-bold mb-2 flex flex-row items-center gap-2 text-red-600"
|
||||
@@ -112,22 +297,37 @@
|
||||
</svg>
|
||||
WARNING: Not Running
|
||||
</span>
|
||||
<span
|
||||
>Rayhunter is not currently running and will not detect abnormal behavior!</span
|
||||
>
|
||||
<span>
|
||||
Rayhunter is not currently running and will not detect abnormal behavior!
|
||||
</span>
|
||||
<div class="flex flex-row justify-end mt-2">
|
||||
<RecordingControls server_is_recording={!!current_entry} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<SystemStatsTable stats={system_stats!} />
|
||||
<SystemStatsTable stats={system_stats!} {gps_data} {gps_mode} />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-xl">History</span>
|
||||
<ManifestTable {entries} server_is_recording={!!current_entry} />
|
||||
<div class="flex flex-row gap-2">
|
||||
<div class="text-xl flex-1">History</div>
|
||||
<div class="flex flex-row items-center gap-2 px-3">
|
||||
<label
|
||||
for="filter_threshold"
|
||||
class="block text-md font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Filter for Warnings
|
||||
</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="filter_threshold"
|
||||
bind:checked={filter_threshold}
|
||||
class="px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ManifestTable {entries} server_is_recording={!!current_entry} {manager} />
|
||||
</div>
|
||||
<DeleteAllButton />
|
||||
<ConfigForm />
|
||||
{:else}
|
||||
<div class="flex flex-col justify-center items-center">
|
||||
<!-- https://www.w3.org/WAI/tutorials/images/decorative/ -->
|
||||
|
||||
11
daemon/web/src/theme.ts
Normal file
11
daemon/web/src/theme.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/** These are the default Tailwind CSS breakpoints.
|
||||
* We're defining them here so they can be referenced
|
||||
* programmatically in other parts of the application.
|
||||
*/
|
||||
export const breakpoints = {
|
||||
sm: '640px',
|
||||
md: '768px',
|
||||
lg: '1024px',
|
||||
xl: '1280px',
|
||||
'2xl': '1536px',
|
||||
} as const;
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 218 KiB |
@@ -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,11 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
target: process.env.API_TARGET || 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
configure: (proxy, _options) => {
|
||||
@@ -26,7 +27,7 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [sveltekit()],
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
build: {
|
||||
// Force everything into one HTML file. SvelteKit will still generate
|
||||
// a lot of JS files but they are deadweight and will not be included
|
||||
|
||||
55
dist/config.toml.in
vendored
55
dist/config.toml.in
vendored
@@ -12,6 +12,7 @@ colorblind_mode = false
|
||||
# 1 = Subtle mode, display a colored line at the top of the screen when rayhunter is running (green=running, white=paused, red=warnings)
|
||||
# 2 = Demo Mode, display a fun orca gif
|
||||
# 3 = display the EFF logo
|
||||
# 4 = High Visibility mode, fill the entire screen with the status color (green=running, white=paused, red=warnings)
|
||||
#
|
||||
# TP-Link with one-bit display:
|
||||
# 0 = invisible mode
|
||||
@@ -19,9 +20,57 @@ colorblind_mode = false
|
||||
ui_level = 1
|
||||
|
||||
# 0 = rayhunter does not read button presses
|
||||
# 1 = double-tapping the power button starts/stops recordings
|
||||
# 1 = double-tapping the power button starts new recording
|
||||
key_input_mode = 0
|
||||
|
||||
# If set, attempts to send a notification to the url when a new warning is triggered
|
||||
# ntfy_url = "https://ntfy.sh/your-topic"
|
||||
# What notification types to enable. Does nothing if the above ntfy_url is not set.
|
||||
enabled_notifications = ["Warning", "LowBattery"]
|
||||
|
||||
# If true, Rayhunter will periodically check GitHub for new releases and show
|
||||
# an update notice in the web UI.
|
||||
auto_check_updates = false
|
||||
|
||||
# Disk Space Management
|
||||
# Minimum free space (MB) required to start recording
|
||||
min_space_to_start_recording_mb = 1
|
||||
# Minimum free space (MB) to continue recording (stops if below this)
|
||||
min_space_to_continue_recording_mb = 1
|
||||
|
||||
# WiFi Client Mode
|
||||
# Toggle wifi_enabled to connect the device to an existing WiFi network.
|
||||
# Credentials are stored separately in wpa_sta.conf and managed via the web UI.
|
||||
wifi_enabled = false
|
||||
|
||||
# DNS servers to use when WiFi client mode is active.
|
||||
# Defaults to ["9.9.9.9", "149.112.112.112"] (Quad9) if not specified.
|
||||
# dns_servers = ["9.9.9.9", "149.112.112.112"]
|
||||
|
||||
# WebDAV Upload
|
||||
# If a [webdav] section is present, finished recordings (both the raw .qmdl file
|
||||
# and its .ndjson analysis output) are uploaded in the background to a WebDAV
|
||||
# server once they've been closed for at least min_age_secs. After a successful
|
||||
# upload the entry is either marked as uploaded in the manifest, or deleted
|
||||
# locally if delete_on_upload = true. With no [webdav] section, no upload
|
||||
# worker runs.
|
||||
#
|
||||
# [webdav]
|
||||
# url = "https://dav.example.com/rayhunter"
|
||||
# # HTTP Basic auth. Both fields are optional; a password without a username is
|
||||
# # rejected and the request is sent unauthenticated.
|
||||
# username = "user"
|
||||
# password = "pass"
|
||||
# # Timeout in seconds for each upload request (default 300).
|
||||
# upload_timeout_secs = 300
|
||||
# # How often the worker scans for eligible entries (default 3600).
|
||||
# poll_interval_secs = 3600
|
||||
# # Minimum age in seconds before an entry becomes eligible for upload
|
||||
# # (default 86400 = 1 day).
|
||||
# min_age_secs = 86400
|
||||
# # Delete the entry locally after a successful upload (default false).
|
||||
# delete_on_upload = false
|
||||
|
||||
# Analyzer Configuration
|
||||
# Enable/disable specific IMSI catcher detection heuristics
|
||||
# See https://github.com/EFForg/rayhunter/blob/main/doc/heuristics.md for details
|
||||
@@ -29,6 +78,8 @@ key_input_mode = 0
|
||||
imsi_requested = true
|
||||
connection_redirect_2g_downgrade = true
|
||||
lte_sib6_and_7_downgrade = true
|
||||
null_cipher = true
|
||||
null_cipher = true
|
||||
nas_null_cipher = true
|
||||
incomplete_sib = true
|
||||
test_analyzer = false
|
||||
diagnostic_analyzer = true
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 152 KiB |
BIN
doc/Rayhunter_0.5.0.png
Normal file
BIN
doc/Rayhunter_0.5.0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
@@ -1,22 +1,26 @@
|
||||
# Summary
|
||||
|
||||
[Introduction](./introduction.md)
|
||||
- [Introduction](./introduction.md)
|
||||
- [Support, feedback, and community](./support-feedback-community.md)
|
||||
- [Frequently Asked Questions](./faq.md)
|
||||
- [Installation](./installation.md)
|
||||
- [Installing from the latest release](./installing-from-release.md)
|
||||
- [Installing from the latest release (Windows)](./installing-from-release-windows.md)
|
||||
- [Installing from source](./installing-from-source.md)
|
||||
- [Updating Rayhunter](./updating-rayhunter.md)
|
||||
- [Configuration](./configuration.md)
|
||||
- [Uninstalling](./uninstalling.md)
|
||||
- [Using Rayhunter](./using-rayhunter.md)
|
||||
- [Rayhunter's heuristics](./heuristics.md)
|
||||
- [Re-analyzing recordings](./reanalyzing.md)
|
||||
- [How we analyze a capture](./analyzing-a-capture.md)
|
||||
- [Supported devices](./supported-devices.md)
|
||||
- [Orbic RC400L](./orbic.md)
|
||||
- [Porting to new devices](./porting.md)
|
||||
- [Orbic/Kajeet RC400L](./orbic.md)
|
||||
- [TP-Link M7350](./tplink-m7350.md)
|
||||
- [TP-Link M7310](./tplink-m7310.md)
|
||||
- [Tmobile TMOHS1](./tmobile-tmohs1.md)
|
||||
- [UZ801](./uz801.md)
|
||||
- [Wingtech CT2MHS01](./wingtech-ct2mhs01.md)
|
||||
- [PinePhone and PinePhone Pro](./pinephone.md)
|
||||
- [Support, feedback, and community](./support-feedback-community.md)
|
||||
- [Frequently Asked Questions](./faq.md)
|
||||
- [Moxee Hotspot](./moxee.md)
|
||||
- [REST API Documentation](./api-docs.md)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# How we analyze a capture
|
||||
|
||||
TODO
|
||||
Teams of highly trained squirrels. Video coming soon!
|
||||
5
doc/api-docs.md
Normal file
5
doc/api-docs.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# REST API Documentation
|
||||
|
||||
The rayhunter daemon has [REST API documentation](./api-docs/) available in the interactive swagger-ui.
|
||||
|
||||
>**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.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user