mirror of
https://github.com/EFForg/rayhunter.git
synced 2026-07-01 14:28:56 -07:00
Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c39235d5e4 | |||
| 21e12d16e1 | |||
| 892c833344 | |||
| 3a7f512842 | |||
| 8d8099f66b | |||
| 7831c5085e | |||
| 956b719e12 | |||
| 73bcb05a0b | |||
| 6c8b6ec64f | |||
| 63ea5df6f7 | |||
| 2d63fc00c7 | |||
| 2927f791e4 | |||
| ec5b306311 | |||
| a5c86bc408 | |||
| 338d41dceb | |||
| 759b2ea4c5 | |||
| a51cafbb14 | |||
| ddc39cf516 | |||
| 66d83ea575 | |||
| 0028847b73 | |||
| 6fec428c5b | |||
| 11b6af96c7 | |||
| 76c2039556 | |||
| c424902560 | |||
| 8f03ad8f4a | |||
| 68ba2ab625 | |||
| 38f476b664 | |||
| cae056d959 | |||
| 9627cec737 | |||
| 10f560b5e4 | |||
| 88d2725427 | |||
| 76ae8fccd9 | |||
| 94b989c3c0 | |||
| f5a0cddc88 | |||
| 2702ee0828 | |||
| 58338850dd | |||
| d122ce6e6d | |||
| 9280067e31 | |||
| 3b3532d3fd | |||
| 30c4cb0e0c | |||
| 17a9dfe0ff | |||
| b3f63864ad | |||
| cfefa3c901 | |||
| ce821b825f | |||
| 772dac681e | |||
| 2df331a0bc | |||
| 0a1dce3215 | |||
| 2df31300e3 | |||
| fbd8110be9 | |||
| 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 |
@@ -44,11 +44,12 @@ jobs:
|
|||||||
|
|
||||||
# We rebuild everything if any of these conditions hold:
|
# We rebuild everything if any of these conditions hold:
|
||||||
# * We are on main
|
# * We are on main
|
||||||
|
# * The run was triggered by a tag
|
||||||
# * Changes are made to github workflows
|
# * Changes are made to github workflows
|
||||||
# * A cargo-workspace file changed (lockfile or .cargo), as that could affect any crate anywhere
|
# * 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)
|
# * 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
|
# * #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'
|
if [ ${GITHUB_REF} = 'refs/heads/main' ] || [ ${GITHUB_REF_TYPE} = 'tag' ] || 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
|
then
|
||||||
echo "building everything"
|
echo "building everything"
|
||||||
echo code_count=forced >> "$GITHUB_OUTPUT"
|
echo code_count=forced >> "$GITHUB_OUTPUT"
|
||||||
@@ -172,6 +173,9 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
components: clippy
|
components: clippy
|
||||||
- uses: Swatinem/rust-cache@v2
|
- 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
|
# 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
|
# fmt --all runs on all workspace packages so this is handled by
|
||||||
# check_and_test above
|
# check_and_test above
|
||||||
@@ -275,6 +279,9 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
targets: ${{ matrix.platform.target }}
|
targets: ${{ matrix.platform.target }}
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
# Workaround for https://github.com/Swatinem/rust-cache/issues/341
|
||||||
|
cache-bin: 'false'
|
||||||
- name: Build rayhunter-check
|
- name: Build rayhunter-check
|
||||||
run: cargo build --bin rayhunter-check --release --target ${{ matrix.platform.target }}
|
run: cargo build --bin rayhunter-check --release --target ${{ matrix.platform.target }}
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
@@ -411,6 +418,9 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
targets: ${{ matrix.platform.target }}
|
targets: ${{ matrix.platform.target }}
|
||||||
- uses: Swatinem/rust-cache@v2
|
- 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 }}
|
- run: cargo build --package installer --bin installer --release --target ${{ matrix.platform.target }}
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -426,6 +436,7 @@ jobs:
|
|||||||
needs:
|
needs:
|
||||||
- build_rayhunter
|
- build_rayhunter
|
||||||
- build_rootshell
|
- build_rootshell
|
||||||
|
- build_wpa_supplicant
|
||||||
- files_changed
|
- files_changed
|
||||||
- installer_gui_check
|
- installer_gui_check
|
||||||
- test_installer_frontend
|
- test_installer_frontend
|
||||||
@@ -482,6 +493,7 @@ jobs:
|
|||||||
needs:
|
needs:
|
||||||
- build_rayhunter
|
- build_rayhunter
|
||||||
- build_rootshell
|
- build_rootshell
|
||||||
|
- build_wpa_supplicant
|
||||||
- files_changed
|
- files_changed
|
||||||
- installer_gui_check
|
- installer_gui_check
|
||||||
- test_installer_frontend
|
- test_installer_frontend
|
||||||
@@ -502,6 +514,10 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
targets: ${{ matrix.platform.target }}
|
targets: ${{ matrix.platform.target }}
|
||||||
- uses: Swatinem/rust-cache@v2
|
- 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
|
- name: Build GUI installer
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -525,6 +541,7 @@ jobs:
|
|||||||
needs:
|
needs:
|
||||||
- build_rayhunter
|
- build_rayhunter
|
||||||
- build_rootshell
|
- build_rootshell
|
||||||
|
- build_wpa_supplicant
|
||||||
- files_changed
|
- files_changed
|
||||||
- installer_gui_check
|
- installer_gui_check
|
||||||
- test_installer_frontend
|
- test_installer_frontend
|
||||||
@@ -628,7 +645,7 @@ jobs:
|
|||||||
- name: Build rayhunter-daemon openapi docs
|
- name: Build rayhunter-daemon openapi docs
|
||||||
run: |
|
run: |
|
||||||
mkdir -p daemon/web/build
|
mkdir -p daemon/web/build
|
||||||
touch daemon/web/build/{favicon.png,index.html.br,rayhunter_orca_only.png,rayhunter_text.png}
|
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
|
cargo run --bin gen_api --features apidocs -- ./rayhunter-openapi.json
|
||||||
- name: Make swagger folder
|
- name: Make swagger folder
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
# To use: navigate on Github to Actions, select "Release rayhunter" on the left, click "Run workflow" > "Run workflow" on the right.
|
# To learn how to use this workflow, please read CONTRIBUTING.md.
|
||||||
# https://github.com/EFForg/rayhunter/actions/workflows/release.yml
|
|
||||||
name: Release rayhunter
|
name: Release rayhunter
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
push:
|
||||||
|
tags:
|
||||||
|
- "v[0-9]+.[0-9]+.[0-9]+"
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
|||||||
+4
-6
@@ -75,11 +75,9 @@ You can read our [full policy](https://www.eff.org/about/opportunities/volunteer
|
|||||||
This one is for maintainers of Rayhunter.
|
This one is for maintainers of Rayhunter.
|
||||||
|
|
||||||
1. Make a PR changing the versions in `Cargo.toml` and other files.
|
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:
|
This can be done by running `scripts/set-versions.sh VERSION_NUM`.
|
||||||
`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.
|
2. Merge the PR, make a tag, and push the tag to GitHub. Pushing the tag should
|
||||||
|
trigger the [release workflow](https://github.com/EFForg/rayhunter/actions/workflows/release.yml).
|
||||||
|
|
||||||
3. [Run release workflow.](https://github.com/EFForg/rayhunter/actions/workflows/release.yml)
|
3. Write changelog, edit it into the release, announce on mattermost.
|
||||||
|
|
||||||
4. Write changelog, edit it into the release, announce on mattermost.
|
|
||||||
|
|||||||
Generated
+61
-34
@@ -274,6 +274,18 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-compression"
|
||||||
|
version = "0.4.42"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac"
|
||||||
|
dependencies = [
|
||||||
|
"compression-codecs",
|
||||||
|
"compression-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-executor"
|
name = "async-executor"
|
||||||
version = "1.13.3"
|
version = "1.13.3"
|
||||||
@@ -992,6 +1004,23 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "compression-codecs"
|
||||||
|
version = "0.4.38"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf"
|
||||||
|
dependencies = [
|
||||||
|
"compression-core",
|
||||||
|
"flate2",
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "compression-core"
|
||||||
|
version = "0.4.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "concurrent-queue"
|
name = "concurrent-queue"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -1876,9 +1905,9 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flate2"
|
name = "flate2"
|
||||||
version = "1.1.1"
|
version = "1.1.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece"
|
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crc32fast",
|
"crc32fast",
|
||||||
"miniz_oxide",
|
"miniz_oxide",
|
||||||
@@ -1967,9 +1996,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures"
|
name = "futures"
|
||||||
version = "0.3.31"
|
version = "0.3.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
|
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
@@ -1982,9 +2011,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.31"
|
version = "0.3.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-sink",
|
"futures-sink",
|
||||||
@@ -1992,15 +2021,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-core"
|
name = "futures-core"
|
||||||
version = "0.3.31"
|
version = "0.3.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
|
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-executor"
|
name = "futures-executor"
|
||||||
version = "0.3.31"
|
version = "0.3.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
|
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-task",
|
"futures-task",
|
||||||
@@ -2009,9 +2038,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-io"
|
name = "futures-io"
|
||||||
version = "0.3.31"
|
version = "0.3.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
|
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-lite"
|
name = "futures-lite"
|
||||||
@@ -2028,9 +2057,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-macro"
|
name = "futures-macro"
|
||||||
version = "0.3.31"
|
version = "0.3.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
|
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -2039,21 +2068,21 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-sink"
|
name = "futures-sink"
|
||||||
version = "0.3.31"
|
version = "0.3.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
|
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-task"
|
name = "futures-task"
|
||||||
version = "0.3.31"
|
version = "0.3.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
|
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-util"
|
name = "futures-util"
|
||||||
version = "0.3.31"
|
version = "0.3.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
@@ -2063,7 +2092,6 @@ dependencies = [
|
|||||||
"futures-task",
|
"futures-task",
|
||||||
"memchr",
|
"memchr",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"pin-utils",
|
|
||||||
"slab",
|
"slab",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2886,7 +2914,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "installer"
|
name = "installer"
|
||||||
version = "0.11.0"
|
version = "0.11.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"adb_client",
|
"adb_client",
|
||||||
"aes",
|
"aes",
|
||||||
@@ -2914,10 +2942,12 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "installer-gui"
|
name = "installer-gui"
|
||||||
version = "0.11.0"
|
version = "0.11.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"clap",
|
||||||
"installer",
|
"installer",
|
||||||
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"shlex",
|
"shlex",
|
||||||
@@ -4326,12 +4356,6 @@ version = "0.2.16"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
|
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pin-utils"
|
|
||||||
version = "0.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "piper"
|
name = "piper"
|
||||||
version = "0.2.4"
|
version = "0.2.4"
|
||||||
@@ -4824,8 +4848,9 @@ checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rayhunter"
|
name = "rayhunter"
|
||||||
version = "0.11.0"
|
version = "0.11.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-compression",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
"crc",
|
"crc",
|
||||||
@@ -4849,7 +4874,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rayhunter-check"
|
name = "rayhunter-check"
|
||||||
version = "0.11.0"
|
version = "0.11.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -4862,7 +4887,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rayhunter-daemon"
|
name = "rayhunter-daemon"
|
||||||
version = "0.11.0"
|
version = "0.11.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -4881,6 +4906,7 @@ dependencies = [
|
|||||||
"rustls-rustcrypto",
|
"rustls-rustcrypto",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serde_repr",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -4987,6 +5013,7 @@ version = "0.12.20"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813"
|
checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-compression",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
@@ -5087,7 +5114,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rootshell"
|
name = "rootshell"
|
||||||
version = "0.11.0"
|
version = "0.11.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"nix 0.29.0",
|
"nix 0.29.0",
|
||||||
]
|
]
|
||||||
@@ -6207,7 +6234,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "telcom-parser"
|
name = "telcom-parser"
|
||||||
version = "0.11.0"
|
version = "0.11.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"asn1-codecs",
|
"asn1-codecs",
|
||||||
"asn1-compiler",
|
"asn1-compiler",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rayhunter-check"
|
name = "rayhunter-check"
|
||||||
version = "0.11.0"
|
version = "0.11.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
+26
-34
@@ -1,15 +1,10 @@
|
|||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use futures::TryStreamExt;
|
|
||||||
use log::{debug, error, info, warn};
|
use log::{debug, error, info, warn};
|
||||||
use pcap_file_tokio::pcapng::{Block, PcapNgReader};
|
use pcap_file_tokio::pcapng::{Block, PcapNgReader};
|
||||||
use rayhunter::{
|
use rayhunter::{
|
||||||
analysis::analyzer::{AnalysisRow, AnalyzerConfig, EventType, Harness},
|
analysis::analyzer::{AnalysisRow, AnalyzerConfig, EventType, Harness}, diag::Message, gsmtap::parser as gsmtap_parser, pcap::GsmtapPcapWriter, qmdl::QmdlMessageReader,
|
||||||
diag::DataType,
|
|
||||||
gsmtap_parser,
|
|
||||||
pcap::GsmtapPcapWriter,
|
|
||||||
qmdl::QmdlReader,
|
|
||||||
};
|
};
|
||||||
use std::{collections::HashMap, future, path::PathBuf, pin::pin};
|
use std::{collections::HashMap, path::PathBuf};
|
||||||
use tokio::fs::File;
|
use tokio::fs::File;
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
@@ -113,26 +108,24 @@ async fn analyze_pcap(pcap_path: &str, show_skipped: bool) {
|
|||||||
async fn analyze_qmdl(qmdl_path: &str, show_skipped: bool) {
|
async fn analyze_qmdl(qmdl_path: &str, show_skipped: bool) {
|
||||||
let mut harness = Harness::new_with_config(&AnalyzerConfig::default());
|
let mut harness = Harness::new_with_config(&AnalyzerConfig::default());
|
||||||
let qmdl_file = &mut File::open(&qmdl_path).await.expect("failed to open file");
|
let qmdl_file = &mut File::open(&qmdl_path).await.expect("failed to open file");
|
||||||
let file_size = qmdl_file
|
let mut qmdl_reader = QmdlMessageReader::new(qmdl_file)
|
||||||
.metadata()
|
|
||||||
.await
|
.await
|
||||||
.expect("failed to get QMDL file metadata")
|
.expect("failed to open QmdlReader");
|
||||||
.len();
|
|
||||||
let mut qmdl_reader = QmdlReader::new(qmdl_file, Some(file_size as usize));
|
|
||||||
let mut qmdl_stream = pin!(
|
|
||||||
qmdl_reader
|
|
||||||
.as_stream()
|
|
||||||
.try_filter(|container| future::ready(container.data_type == DataType::UserSpace))
|
|
||||||
);
|
|
||||||
let mut report = Report::new(qmdl_path);
|
let mut report = Report::new(qmdl_path);
|
||||||
while let Some(container) = qmdl_stream
|
let mut stats = HashMap::new();
|
||||||
.try_next()
|
while let Some(buf) = qmdl_reader
|
||||||
|
.get_next_buf()
|
||||||
.await
|
.await
|
||||||
.expect("failed getting QMDL container")
|
.expect("failed to get message")
|
||||||
{
|
{
|
||||||
for row in harness.analyze_qmdl_messages(container) {
|
let log_type = u16::from_le_bytes([buf[6], buf[7]]);
|
||||||
report.process_row(row);
|
stats.entry(log_type)
|
||||||
|
.and_modify(|total| *total += buf.len())
|
||||||
|
.or_insert(buf.len());
|
||||||
|
report.process_row(harness.analyze_qmdl_message(Message::from_hdlc(&buf)));
|
||||||
}
|
}
|
||||||
|
for (id, size) in stats {
|
||||||
|
println!("{id:04x}: {size}");
|
||||||
}
|
}
|
||||||
report.print_summary(show_skipped);
|
report.print_summary(show_skipped);
|
||||||
}
|
}
|
||||||
@@ -141,8 +134,9 @@ async fn pcapify(qmdl_path: &PathBuf) {
|
|||||||
let qmdl_file = &mut File::open(&qmdl_path)
|
let qmdl_file = &mut File::open(&qmdl_path)
|
||||||
.await
|
.await
|
||||||
.expect("failed to open qmdl file");
|
.expect("failed to open qmdl file");
|
||||||
let qmdl_file_size = qmdl_file.metadata().await.unwrap().len();
|
let mut qmdl_reader = QmdlMessageReader::new(qmdl_file)
|
||||||
let mut qmdl_reader = QmdlReader::new(qmdl_file, Some(qmdl_file_size as usize));
|
.await
|
||||||
|
.expect("failed to open QmdlReader");
|
||||||
let mut pcap_path = qmdl_path.clone();
|
let mut pcap_path = qmdl_path.clone();
|
||||||
pcap_path.set_extension("pcapng");
|
pcap_path.set_extension("pcapng");
|
||||||
let pcap_file = &mut File::create(&pcap_path)
|
let pcap_file = &mut File::create(&pcap_path)
|
||||||
@@ -150,20 +144,20 @@ async fn pcapify(qmdl_path: &PathBuf) {
|
|||||||
.expect("failed to open pcap file");
|
.expect("failed to open pcap file");
|
||||||
let mut pcap_writer = GsmtapPcapWriter::new(pcap_file).await.unwrap();
|
let mut pcap_writer = GsmtapPcapWriter::new(pcap_file).await.unwrap();
|
||||||
pcap_writer.write_iface_header().await.unwrap();
|
pcap_writer.write_iface_header().await.unwrap();
|
||||||
while let Some(container) = qmdl_reader
|
while let Some(maybe_message) = qmdl_reader
|
||||||
.get_next_messages_container()
|
.get_next_message()
|
||||||
.await
|
.await
|
||||||
.expect("failed to get container")
|
.expect("failed to get message")
|
||||||
|
{
|
||||||
|
if let Ok(msg) = maybe_message
|
||||||
|
&& let Ok(Some((timestamp, parsed))) = gsmtap_parser::parse(msg)
|
||||||
{
|
{
|
||||||
for msg in container.into_messages().into_iter().flatten() {
|
|
||||||
if let Ok(Some((timestamp, parsed))) = gsmtap_parser::parse(msg) {
|
|
||||||
pcap_writer
|
pcap_writer
|
||||||
.write_gsmtap_message(parsed, timestamp)
|
.write_gsmtap_message(parsed, timestamp, None)
|
||||||
.await
|
.await
|
||||||
.expect("failed to write");
|
.expect("failed to write");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
info!("wrote pcap to {:?}", &pcap_path);
|
info!("wrote pcap to {:?}", &pcap_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,9 +191,7 @@ async fn main() {
|
|||||||
let name_str = name.to_str().unwrap();
|
let name_str = name.to_str().unwrap();
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
let path_str = path.to_str().unwrap();
|
let path_str = path.to_str().unwrap();
|
||||||
// instead of relying on the QMDL extension, can we check if a file is
|
if name_str.ends_with(".qmdl") || name_str.ends_with(".qmdl.gz") {
|
||||||
// QMDL by inspecting the contents?
|
|
||||||
if name_str.ends_with(".qmdl") {
|
|
||||||
info!("**** Beginning analysis of {name_str}");
|
info!("**** Beginning analysis of {name_str}");
|
||||||
analyze_qmdl(path_str, args.show_skipped).await;
|
analyze_qmdl(path_str, args.show_skipped).await;
|
||||||
if args.pcapify {
|
if args.pcapify {
|
||||||
|
|||||||
+7
-2
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rayhunter-daemon"
|
name = "rayhunter-daemon"
|
||||||
version = "0.11.0"
|
version = "0.11.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.88.0"
|
rust-version = "1.88.0"
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ rayhunter = { path = "../lib" }
|
|||||||
wifi-station = "0.10.1"
|
wifi-station = "0.10.1"
|
||||||
toml = "0.8.8"
|
toml = "0.8.8"
|
||||||
serde = { version = "1.0.193", features = ["derive"] }
|
serde = { version = "1.0.193", features = ["derive"] }
|
||||||
|
serde_repr = "0.1"
|
||||||
tokio = { version = "1.44.2", default-features = false, features = ["fs", "signal", "process", "rt"] }
|
tokio = { version = "1.44.2", default-features = false, features = ["fs", "signal", "process", "rt"] }
|
||||||
axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json"] }
|
axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json"] }
|
||||||
thiserror = "1.0.52"
|
thiserror = "1.0.52"
|
||||||
@@ -34,7 +35,7 @@ futures-macro = "0.3.30"
|
|||||||
include_dir = "0.7.3"
|
include_dir = "0.7.3"
|
||||||
chrono = { version = "0.4.31", features = ["serde"] }
|
chrono = { version = "0.4.31", features = ["serde"] }
|
||||||
tokio-stream = { version = "0.1.14", default-features = false, features = ["io-util"] }
|
tokio-stream = { version = "0.1.14", default-features = false, features = ["io-util"] }
|
||||||
futures = { version = "0.3.30", default-features = false }
|
futures = { version = "0.3.32", default-features = false, features = ["std"] }
|
||||||
serde_json = "1.0.114"
|
serde_json = "1.0.114"
|
||||||
image = { version = "0.25.1", default-features = false, features = ["png", "gif"] }
|
image = { version = "0.25.1", default-features = false, features = ["png", "gif"] }
|
||||||
tempfile = "3.10.2"
|
tempfile = "3.10.2"
|
||||||
@@ -46,3 +47,7 @@ rustls-post-quantum = { version = "0.2.4", optional = true }
|
|||||||
async-trait = "0.1.88"
|
async-trait = "0.1.88"
|
||||||
utoipa = { version = "5.4.0", optional = true }
|
utoipa = { version = "5.4.0", optional = true }
|
||||||
url = "2.5.4"
|
url = "2.5.4"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
reqwest = { version = "0.12.20", default-features = false, features = ["gzip"] }
|
||||||
|
tempfile = "3.10.2"
|
||||||
|
|||||||
+27
-24
@@ -1,16 +1,15 @@
|
|||||||
|
use std::cmp;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::{cmp, future, pin};
|
|
||||||
|
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
};
|
};
|
||||||
use futures::TryStreamExt;
|
|
||||||
use log::{error, info};
|
use log::{error, info};
|
||||||
use rayhunter::analysis::analyzer::{AnalyzerConfig, EventType, Harness};
|
use rayhunter::analysis::analyzer::{AnalyzerConfig, EventType, Harness};
|
||||||
use rayhunter::diag::{DataType, MessagesContainer};
|
use rayhunter::diag::{DiagParsingError, Message, MessagesContainer};
|
||||||
use rayhunter::qmdl::QmdlReader;
|
use rayhunter::qmdl::QmdlMessageReader;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tokio::fs::File;
|
use tokio::fs::File;
|
||||||
use tokio::io::{AsyncWriteExt, BufWriter};
|
use tokio::io::{AsyncWriteExt, BufWriter};
|
||||||
@@ -18,7 +17,7 @@ use tokio::sync::mpsc::Receiver;
|
|||||||
use tokio::sync::{RwLock, RwLockWriteGuard};
|
use tokio::sync::{RwLock, RwLockWriteGuard};
|
||||||
use tokio_util::task::TaskTracker;
|
use tokio_util::task::TaskTracker;
|
||||||
|
|
||||||
use crate::qmdl_store::RecordingStore;
|
use crate::qmdl_store::{FileKind, RecordingStore};
|
||||||
use crate::server::ServerState;
|
use crate::server::ServerState;
|
||||||
|
|
||||||
pub struct AnalysisWriter {
|
pub struct AnalysisWriter {
|
||||||
@@ -47,7 +46,7 @@ impl AnalysisWriter {
|
|||||||
|
|
||||||
// Runs the analysis harness on the given container, serializing the results
|
// Runs the analysis harness on the given container, serializing the results
|
||||||
// to the analysis file, returning the whether any warnings were detected
|
// to the analysis file, returning the whether any warnings were detected
|
||||||
pub async fn analyze(
|
pub async fn analyze_container(
|
||||||
&mut self,
|
&mut self,
|
||||||
container: MessagesContainer,
|
container: MessagesContainer,
|
||||||
) -> Result<EventType, std::io::Error> {
|
) -> Result<EventType, std::io::Error> {
|
||||||
@@ -62,6 +61,17 @@ impl AnalysisWriter {
|
|||||||
Ok(max_type)
|
Ok(max_type)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn analyze_message(
|
||||||
|
&mut self,
|
||||||
|
maybe_qmdl_msg: Result<Message, DiagParsingError>,
|
||||||
|
) -> Result<EventType, std::io::Error> {
|
||||||
|
let row = self.harness.analyze_qmdl_message(maybe_qmdl_msg);
|
||||||
|
if !row.is_empty() {
|
||||||
|
self.write(&row).await?;
|
||||||
|
}
|
||||||
|
Ok(row.get_max_event_type())
|
||||||
|
}
|
||||||
|
|
||||||
async fn write<T: Serialize>(&mut self, value: &T) -> Result<(), std::io::Error> {
|
async fn write<T: Serialize>(&mut self, value: &T) -> Result<(), std::io::Error> {
|
||||||
let mut value_str = serde_json::to_string(value).unwrap();
|
let mut value_str = serde_json::to_string(value).unwrap();
|
||||||
value_str.push('\n');
|
value_str.push('\n');
|
||||||
@@ -135,7 +145,7 @@ async fn perform_analysis(
|
|||||||
analyzer_config: &AnalyzerConfig,
|
analyzer_config: &AnalyzerConfig,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
info!("Opening QMDL and analysis file for {name}...");
|
info!("Opening QMDL and analysis file for {name}...");
|
||||||
let (analysis_file, qmdl_file) = {
|
let (analysis_file, mut qmdl_reader) = {
|
||||||
let mut qmdl_store = qmdl_store_lock.write().await;
|
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||||
let (entry_index, _) = qmdl_store
|
let (entry_index, _) = qmdl_store
|
||||||
.entry_for_name(name)
|
.entry_for_name(name)
|
||||||
@@ -145,36 +155,29 @@ async fn perform_analysis(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("{e:?}"))?;
|
.map_err(|e| format!("{e:?}"))?;
|
||||||
let qmdl_file = qmdl_store
|
let qmdl_file = qmdl_store
|
||||||
.open_entry_qmdl(entry_index)
|
.open_file(entry_index, FileKind::Qmdl)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{e:?}"))?
|
||||||
|
.ok_or("QMDL file not found")?;
|
||||||
|
let qmdl_reader = QmdlMessageReader::new(qmdl_file)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("{e:?}"))?;
|
.map_err(|e| format!("{e:?}"))?;
|
||||||
|
|
||||||
(analysis_file, qmdl_file)
|
(analysis_file, qmdl_reader)
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut analysis_writer = AnalysisWriter::new(analysis_file, analyzer_config)
|
let mut analysis_writer = AnalysisWriter::new(analysis_file, analyzer_config)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("{e:?}"))?;
|
.map_err(|e| format!("{e:?}"))?;
|
||||||
let file_size = qmdl_file
|
|
||||||
.metadata()
|
|
||||||
.await
|
|
||||||
.expect("failed to get QMDL file metadata")
|
|
||||||
.len();
|
|
||||||
let mut qmdl_reader = QmdlReader::new(qmdl_file, Some(file_size as usize));
|
|
||||||
let mut qmdl_stream = pin::pin!(
|
|
||||||
qmdl_reader
|
|
||||||
.as_stream()
|
|
||||||
.try_filter(|container| future::ready(container.data_type == DataType::UserSpace))
|
|
||||||
);
|
|
||||||
|
|
||||||
info!("Starting analysis for {name}...");
|
info!("Starting analysis for {name}...");
|
||||||
while let Some(container) = qmdl_stream
|
while let Some(maybe_message) = qmdl_reader
|
||||||
.try_next()
|
.get_next_message()
|
||||||
.await
|
.await
|
||||||
.expect("failed getting QMDL container")
|
.expect("failed to get message")
|
||||||
{
|
{
|
||||||
let _ = analysis_writer
|
let _ = analysis_writer
|
||||||
.analyze(container)
|
.analyze_message(maybe_message)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("{e:?}"))?;
|
.map_err(|e| format!("{e:?}"))?;
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-4
@@ -1,10 +1,40 @@
|
|||||||
use log::warn;
|
use log::warn;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_repr::{Deserialize_repr, Serialize_repr};
|
||||||
|
|
||||||
use rayhunter::Device;
|
use rayhunter::Device;
|
||||||
use rayhunter::analysis::analyzer::AnalyzerConfig;
|
use rayhunter::analysis::analyzer::AnalyzerConfig;
|
||||||
|
|
||||||
use crate::error::RayhunterError;
|
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;
|
use crate::notifications::NotificationType;
|
||||||
|
|
||||||
/// The structure of a valid rayhunter configuration
|
/// The structure of a valid rayhunter configuration
|
||||||
@@ -21,21 +51,29 @@ pub struct Config {
|
|||||||
/// Internal device name
|
/// Internal device name
|
||||||
pub device: Device,
|
pub device: Device,
|
||||||
/// UI level
|
/// UI level
|
||||||
pub ui_level: u8,
|
pub ui_level: UiLevel,
|
||||||
/// Colorblind mode
|
/// Colorblind mode
|
||||||
pub colorblind_mode: bool,
|
pub colorblind_mode: bool,
|
||||||
/// Key input mode
|
/// Key input mode
|
||||||
pub key_input_mode: u8,
|
pub key_input_mode: KeyInputMode,
|
||||||
/// ntfy.sh URL
|
/// ntfy.sh URL
|
||||||
pub ntfy_url: Option<String>,
|
pub ntfy_url: Option<String>,
|
||||||
/// Vector containing the types of enabled notifications
|
/// Vector containing the types of enabled notifications
|
||||||
pub enabled_notifications: Vec<NotificationType>,
|
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
|
/// Vector containing the list of enabled analyzers
|
||||||
pub analyzers: AnalyzerConfig,
|
pub analyzers: AnalyzerConfig,
|
||||||
/// Minimum disk space required to start a recording
|
/// Minimum disk space required to start a recording
|
||||||
pub min_space_to_start_recording_mb: u64,
|
pub min_space_to_start_recording_mb: u64,
|
||||||
/// Minimum disk space required to continue a recording
|
/// Minimum disk space required to continue a recording
|
||||||
pub min_space_to_continue_recording_mb: u64,
|
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
|
/// Wifi client SSID
|
||||||
pub wifi_ssid: Option<String>,
|
pub wifi_ssid: Option<String>,
|
||||||
/// Wifi client password
|
/// Wifi client password
|
||||||
@@ -92,14 +130,18 @@ impl Default for Config {
|
|||||||
port: 8080,
|
port: 8080,
|
||||||
debug_mode: false,
|
debug_mode: false,
|
||||||
device: Device::Orbic,
|
device: Device::Orbic,
|
||||||
ui_level: 1,
|
ui_level: UiLevel::Subtle,
|
||||||
colorblind_mode: false,
|
colorblind_mode: false,
|
||||||
key_input_mode: 0,
|
key_input_mode: KeyInputMode::Disabled,
|
||||||
analyzers: AnalyzerConfig::default(),
|
analyzers: AnalyzerConfig::default(),
|
||||||
ntfy_url: None,
|
ntfy_url: None,
|
||||||
enabled_notifications: vec![NotificationType::Warning, NotificationType::LowBattery],
|
enabled_notifications: vec![NotificationType::Warning, NotificationType::LowBattery],
|
||||||
|
auto_check_updates: true,
|
||||||
min_space_to_start_recording_mb: 1,
|
min_space_to_start_recording_mb: 1,
|
||||||
min_space_to_continue_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_ssid: None,
|
||||||
wifi_password: None,
|
wifi_password: None,
|
||||||
wifi_security: None,
|
wifi_security: None,
|
||||||
|
|||||||
+145
-51
@@ -12,7 +12,9 @@ use futures::{StreamExt, TryStreamExt, future};
|
|||||||
use log::{debug, error, info, warn};
|
use log::{debug, error, info, warn};
|
||||||
use rayhunter::Device;
|
use rayhunter::Device;
|
||||||
use tokio::fs::File;
|
use tokio::fs::File;
|
||||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
|
|
||||||
|
use crate::gps::GpsRecord;
|
||||||
use tokio::sync::mpsc::{Receiver, Sender};
|
use tokio::sync::mpsc::{Receiver, Sender};
|
||||||
use tokio::sync::{RwLock, oneshot};
|
use tokio::sync::{RwLock, oneshot};
|
||||||
use tokio_stream::wrappers::LinesStream;
|
use tokio_stream::wrappers::LinesStream;
|
||||||
@@ -21,14 +23,15 @@ use tokio_util::task::TaskTracker;
|
|||||||
#[cfg(feature = "apidocs")]
|
#[cfg(feature = "apidocs")]
|
||||||
use rayhunter::analysis::analyzer::ReportMetadata;
|
use rayhunter::analysis::analyzer::ReportMetadata;
|
||||||
use rayhunter::analysis::analyzer::{AnalysisLineNormalizer, AnalyzerConfig, EventType};
|
use rayhunter::analysis::analyzer::{AnalysisLineNormalizer, AnalyzerConfig, EventType};
|
||||||
use rayhunter::diag::{DataType, MessagesContainer};
|
use rayhunter::diag::{DataType, Message, MessagesContainer};
|
||||||
use rayhunter::diag_device::DiagDevice;
|
use rayhunter::diag_device::DiagDevice;
|
||||||
use rayhunter::qmdl::QmdlWriter;
|
use rayhunter::qmdl::QmdlWriter;
|
||||||
|
|
||||||
use crate::analysis::{AnalysisCtrlMessage, AnalysisWriter};
|
use crate::analysis::{AnalysisCtrlMessage, AnalysisWriter};
|
||||||
|
use crate::config::GpsMode;
|
||||||
use crate::display;
|
use crate::display;
|
||||||
use crate::notifications::{Notification, NotificationType};
|
use crate::notifications::{Notification, NotificationType};
|
||||||
use crate::qmdl_store::{RecordingStore, RecordingStoreError};
|
use crate::qmdl_store::{FileKind, RecordingStore, RecordingStoreError};
|
||||||
use crate::server::ServerState;
|
use crate::server::ServerState;
|
||||||
use crate::stats::DiskStats;
|
use crate::stats::DiskStats;
|
||||||
|
|
||||||
@@ -37,7 +40,7 @@ const DISK_CHECK_BYTES_INTERVAL: usize = 256 * 1024;
|
|||||||
pub enum DiagDeviceCtrlMessage {
|
pub enum DiagDeviceCtrlMessage {
|
||||||
StopRecording,
|
StopRecording,
|
||||||
StartRecording {
|
StartRecording {
|
||||||
response_tx: Option<oneshot::Sender<Result<(), String>>>,
|
response_tx: Option<oneshot::Sender<Result<(), RecordingStoreError>>>,
|
||||||
},
|
},
|
||||||
DeleteEntry {
|
DeleteEntry {
|
||||||
name: String,
|
name: String,
|
||||||
@@ -46,6 +49,10 @@ pub enum DiagDeviceCtrlMessage {
|
|||||||
DeleteAllEntries {
|
DeleteAllEntries {
|
||||||
response_tx: oneshot::Sender<Result<(), RecordingStoreError>>,
|
response_tx: oneshot::Sender<Result<(), RecordingStoreError>>,
|
||||||
},
|
},
|
||||||
|
GpsUpdate {
|
||||||
|
lat: f64,
|
||||||
|
lon: f64,
|
||||||
|
},
|
||||||
Exit,
|
Exit,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,15 +63,18 @@ pub struct DiagTask {
|
|||||||
notification_channel: tokio::sync::mpsc::Sender<Notification>,
|
notification_channel: tokio::sync::mpsc::Sender<Notification>,
|
||||||
min_space_to_start_mb: u64,
|
min_space_to_start_mb: u64,
|
||||||
min_space_to_continue_mb: u64,
|
min_space_to_continue_mb: u64,
|
||||||
|
gps_mode: GpsMode,
|
||||||
|
gps_fixed_coords: Option<(f64, f64)>,
|
||||||
state: DiagState,
|
state: DiagState,
|
||||||
max_type_seen: EventType,
|
max_type_seen: EventType,
|
||||||
bytes_since_space_check: usize,
|
bytes_since_space_check: usize,
|
||||||
low_space_warned: bool,
|
low_space_warned: bool,
|
||||||
|
latest_packet_timestamp: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum DiagState {
|
enum DiagState {
|
||||||
Recording {
|
Recording {
|
||||||
qmdl_writer: QmdlWriter<File>,
|
qmdl_writer: Box<QmdlWriter<File>>,
|
||||||
analysis_writer: Box<AnalysisWriter>,
|
analysis_writer: Box<AnalysisWriter>,
|
||||||
},
|
},
|
||||||
Stopped,
|
Stopped,
|
||||||
@@ -97,6 +107,7 @@ fn check_disk_space(path: &std::path::Path, warning_mb: u64, critical_mb: u64) -
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DiagTask {
|
impl DiagTask {
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn new(
|
fn new(
|
||||||
ui_update_sender: Sender<display::DisplayState>,
|
ui_update_sender: Sender<display::DisplayState>,
|
||||||
analysis_sender: Sender<AnalysisCtrlMessage>,
|
analysis_sender: Sender<AnalysisCtrlMessage>,
|
||||||
@@ -104,6 +115,8 @@ impl DiagTask {
|
|||||||
notification_channel: tokio::sync::mpsc::Sender<Notification>,
|
notification_channel: tokio::sync::mpsc::Sender<Notification>,
|
||||||
min_space_to_start_mb: u64,
|
min_space_to_start_mb: u64,
|
||||||
min_space_to_continue_mb: u64,
|
min_space_to_continue_mb: u64,
|
||||||
|
gps_mode: GpsMode,
|
||||||
|
gps_fixed_coords: Option<(f64, f64)>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
ui_update_sender,
|
ui_update_sender,
|
||||||
@@ -112,15 +125,18 @@ impl DiagTask {
|
|||||||
notification_channel,
|
notification_channel,
|
||||||
min_space_to_start_mb,
|
min_space_to_start_mb,
|
||||||
min_space_to_continue_mb,
|
min_space_to_continue_mb,
|
||||||
|
gps_mode,
|
||||||
|
gps_fixed_coords,
|
||||||
state: DiagState::Stopped,
|
state: DiagState::Stopped,
|
||||||
max_type_seen: EventType::Informational,
|
max_type_seen: EventType::Informational,
|
||||||
bytes_since_space_check: 0,
|
bytes_since_space_check: 0,
|
||||||
low_space_warned: false,
|
low_space_warned: false,
|
||||||
|
latest_packet_timestamp: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start recording, returning an error if disk space is too low.
|
/// Start recording, returning an error if disk space is too low.
|
||||||
async fn start(&mut self, qmdl_store: &mut RecordingStore) -> Result<(), String> {
|
async fn start(&mut self, qmdl_store: &mut RecordingStore) -> Result<(), RecordingStoreError> {
|
||||||
self.max_type_seen = EventType::Informational;
|
self.max_type_seen = EventType::Informational;
|
||||||
self.bytes_since_space_check = 0;
|
self.bytes_since_space_check = 0;
|
||||||
self.low_space_warned = false;
|
self.low_space_warned = false;
|
||||||
@@ -131,12 +147,10 @@ impl DiagTask {
|
|||||||
self.min_space_to_continue_mb,
|
self.min_space_to_continue_mb,
|
||||||
) {
|
) {
|
||||||
DiskSpaceCheck::Critical(mb) | DiskSpaceCheck::Warning(mb) => {
|
DiskSpaceCheck::Critical(mb) | DiskSpaceCheck::Warning(mb) => {
|
||||||
let msg = format!(
|
return Err(RecordingStoreError::InsufficientDiskSpace(
|
||||||
"Insufficient disk space: {}MB available, {}MB required",
|
mb,
|
||||||
mb, self.min_space_to_start_mb
|
self.min_space_to_start_mb,
|
||||||
);
|
));
|
||||||
error!("{msg}");
|
|
||||||
return Err(msg);
|
|
||||||
}
|
}
|
||||||
DiskSpaceCheck::Ok(mb) => {
|
DiskSpaceCheck::Ok(mb) => {
|
||||||
info!("Starting recording with {}MB disk space available", mb);
|
info!("Starting recording with {}MB disk space available", mb);
|
||||||
@@ -144,29 +158,43 @@ impl DiagTask {
|
|||||||
DiskSpaceCheck::Failed => {}
|
DiskSpaceCheck::Failed => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
let (qmdl_file, analysis_file) = match qmdl_store.new_entry().await {
|
let (qmdl_gz_file, analysis_file) = qmdl_store.new_entry(self.gps_mode).await?;
|
||||||
Ok(files) => files,
|
|
||||||
Err(e) => {
|
// For fixed-mode sessions, write the configured coordinates to the storage
|
||||||
let msg = format!("failed creating QMDL file entry: {e}");
|
// immediately so the per-session GPS is stored durably and isn't affected
|
||||||
error!("{msg}");
|
// by future config changes or GPS API calls.
|
||||||
return Err(msg);
|
if self.gps_mode == GpsMode::Fixed
|
||||||
}
|
&& let Some((lat, lon)) = self.gps_fixed_coords
|
||||||
};
|
&& let Some((entry_idx, _)) = qmdl_store.get_current_entry()
|
||||||
self.stop_current_recording().await;
|
|
||||||
let qmdl_writer = QmdlWriter::new(qmdl_file);
|
|
||||||
let analysis_writer = match AnalysisWriter::new(analysis_file, &self.analyzer_config).await
|
|
||||||
{
|
{
|
||||||
Ok(writer) => Box::new(writer),
|
let mut gps_file = qmdl_store
|
||||||
Err(e) => {
|
.open_entry_gps_for_append(entry_idx)
|
||||||
let msg = format!("failed to create analysis writer: {e}");
|
.await?
|
||||||
error!("{msg}");
|
.ok_or(RecordingStoreError::GpsStorageNotFound)?;
|
||||||
return Err(msg);
|
|
||||||
}
|
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(qmdl_store).await;
|
||||||
|
let qmdl_writer = Box::new(QmdlWriter::new(qmdl_gz_file));
|
||||||
|
let analysis_writer = AnalysisWriter::new(analysis_file, &self.analyzer_config)
|
||||||
|
.await
|
||||||
|
.map_err(RecordingStoreError::WriteFileError)?;
|
||||||
self.state = DiagState::Recording {
|
self.state = DiagState::Recording {
|
||||||
qmdl_writer,
|
qmdl_writer,
|
||||||
analysis_writer,
|
analysis_writer: Box::new(analysis_writer),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = self
|
if let Err(e) = self
|
||||||
.ui_update_sender
|
.ui_update_sender
|
||||||
.send(display::DisplayState::Recording)
|
.send(display::DisplayState::Recording)
|
||||||
@@ -179,7 +207,7 @@ impl DiagTask {
|
|||||||
|
|
||||||
/// Stop recording, optionally annotating the entry with a reason.
|
/// Stop recording, optionally annotating the entry with a reason.
|
||||||
async fn stop(&mut self, qmdl_store: &mut RecordingStore, reason: Option<String>) {
|
async fn stop(&mut self, qmdl_store: &mut RecordingStore, reason: Option<String>) {
|
||||||
self.stop_current_recording().await;
|
self.stop_current_recording(qmdl_store).await;
|
||||||
if let Some(reason) = reason
|
if let Some(reason) = reason
|
||||||
&& let Err(e) = qmdl_store.set_current_stop_reason(reason).await
|
&& let Err(e) = qmdl_store.set_current_stop_reason(reason).await
|
||||||
{
|
{
|
||||||
@@ -234,17 +262,63 @@ impl DiagTask {
|
|||||||
res
|
res
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn stop_current_recording(&mut self) {
|
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, qmdl_store: &mut RecordingStore) {
|
||||||
let mut state = DiagState::Stopped;
|
let mut state = DiagState::Stopped;
|
||||||
std::mem::swap(&mut self.state, &mut state);
|
std::mem::swap(&mut self.state, &mut state);
|
||||||
if let DiagState::Recording {
|
if let DiagState::Recording {
|
||||||
analysis_writer, ..
|
qmdl_writer,
|
||||||
|
analysis_writer,
|
||||||
|
..
|
||||||
} = state
|
} = state
|
||||||
{
|
{
|
||||||
analysis_writer
|
match (qmdl_writer.close().await, analysis_writer.close().await) {
|
||||||
.close()
|
(Ok(size), Ok(())) => {
|
||||||
.await
|
if let Err(err) = qmdl_store.update_current_entry_qmdl_size(size).await {
|
||||||
.expect("failed to close analysis writer");
|
error!("failed to update QMDL entry size while closing it: {err:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(qmdl_result, analysis_result) => {
|
||||||
|
if let Err(err) = qmdl_result {
|
||||||
|
error!("failed to close QmdlWriter: {err:?}");
|
||||||
|
}
|
||||||
|
if let Err(err) = analysis_result {
|
||||||
|
error!("failed to close AnalysisWriter: {err:?}");
|
||||||
|
}
|
||||||
|
panic!();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,26 +386,36 @@ impl DiagTask {
|
|||||||
self.stop(qmdl_store, Some(reason)).await;
|
self.stop(qmdl_store, Some(reason)).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if let Ok(file_size) = qmdl_writer.size().await {
|
||||||
debug!(
|
debug!(
|
||||||
"total QMDL bytes written: {}, updating manifest...",
|
"total QMDL bytes written: {}, updating manifest...",
|
||||||
qmdl_writer.total_written
|
file_size
|
||||||
);
|
);
|
||||||
let index = qmdl_store
|
if let Err(e) = qmdl_store.update_current_entry_qmdl_size(file_size).await {
|
||||||
.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}");
|
let reason = format!("failed to update manifest (disk full?): {e}");
|
||||||
error!("{reason}");
|
error!("{reason}");
|
||||||
self.stop(qmdl_store, Some(reason)).await;
|
self.stop(qmdl_store, Some(reason)).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
debug!("done!");
|
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();
|
let container_bytes: usize = container.messages.iter().map(|m| m.data.len()).sum();
|
||||||
self.bytes_since_space_check += container_bytes;
|
self.bytes_since_space_check += container_bytes;
|
||||||
let max_type = match analysis_writer.analyze(container).await {
|
let max_type = match analysis_writer.analyze_container(container).await {
|
||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("failed to analyze container: {e}");
|
warn!("failed to analyze container: {e}");
|
||||||
@@ -381,6 +465,8 @@ pub fn run_diag_read_thread(
|
|||||||
notification_channel: tokio::sync::mpsc::Sender<Notification>,
|
notification_channel: tokio::sync::mpsc::Sender<Notification>,
|
||||||
min_space_to_start_mb: u64,
|
min_space_to_start_mb: u64,
|
||||||
min_space_to_continue_mb: u64,
|
min_space_to_continue_mb: u64,
|
||||||
|
gps_mode: GpsMode,
|
||||||
|
gps_fixed_coords: Option<(f64, f64)>,
|
||||||
) {
|
) {
|
||||||
task_tracker.spawn(async move {
|
task_tracker.spawn(async move {
|
||||||
info!("Using configuration for device: {0:?}", device);
|
info!("Using configuration for device: {0:?}", device);
|
||||||
@@ -396,7 +482,9 @@ pub fn run_diag_read_thread(
|
|||||||
analyzer_config,
|
analyzer_config,
|
||||||
notification_channel,
|
notification_channel,
|
||||||
min_space_to_start_mb,
|
min_space_to_start_mb,
|
||||||
min_space_to_continue_mb
|
min_space_to_continue_mb,
|
||||||
|
gps_mode,
|
||||||
|
gps_fixed_coords,
|
||||||
);
|
);
|
||||||
qmdl_file_tx
|
qmdl_file_tx
|
||||||
.send(DiagDeviceCtrlMessage::StartRecording { response_tx: None })
|
.send(DiagDeviceCtrlMessage::StartRecording { response_tx: None })
|
||||||
@@ -421,7 +509,8 @@ pub fn run_diag_read_thread(
|
|||||||
// time to go
|
// time to go
|
||||||
Some(DiagDeviceCtrlMessage::Exit) | None => {
|
Some(DiagDeviceCtrlMessage::Exit) | None => {
|
||||||
info!("Diag reader thread exiting...");
|
info!("Diag reader thread exiting...");
|
||||||
diag_task.stop_current_recording().await;
|
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||||
|
diag_task.stop_current_recording(qmdl_store.deref_mut()).await;
|
||||||
return Ok(())
|
return Ok(())
|
||||||
},
|
},
|
||||||
Some(DiagDeviceCtrlMessage::DeleteEntry { name, response_tx }) => {
|
Some(DiagDeviceCtrlMessage::DeleteEntry { name, response_tx }) => {
|
||||||
@@ -438,6 +527,10 @@ pub fn run_diag_read_thread(
|
|||||||
error!("Failed to send delete all entries respons, receiver dropped");
|
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() => {
|
maybe_container = diag_stream.next() => {
|
||||||
@@ -493,7 +586,7 @@ pub async fn start_recording(
|
|||||||
|
|
||||||
match response_rx.await {
|
match response_rx.await {
|
||||||
Ok(Ok(())) => Ok((StatusCode::ACCEPTED, "ok".to_string())),
|
Ok(Ok(())) => Ok((StatusCode::ACCEPTED, "ok".to_string())),
|
||||||
Ok(Err(reason)) => Err((StatusCode::INSUFFICIENT_STORAGE, reason)),
|
Ok(Err(reason)) => Err((StatusCode::INSUFFICIENT_STORAGE, reason.to_string())),
|
||||||
Err(e) => Err((
|
Err(e) => Err((
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
format!("failed to receive start recording response: {e}"),
|
format!("failed to receive start recording response: {e}"),
|
||||||
@@ -663,9 +756,10 @@ pub async fn get_analysis_report(
|
|||||||
))?
|
))?
|
||||||
};
|
};
|
||||||
let analysis_file = qmdl_store
|
let analysis_file = qmdl_store
|
||||||
.open_entry_analysis(entry_index)
|
.open_file(entry_index, FileKind::Analysis)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?;
|
.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
|
// Read and normalize the NDJSON file
|
||||||
let reader = BufReader::new(analysis_file);
|
let reader = BufReader::new(analysis_file);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use image::{AnimationDecoder, DynamicImage, codecs::gif::GifDecoder, imageops::F
|
|||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::config;
|
use crate::config::{self, UiLevel};
|
||||||
use crate::display::DisplayState;
|
use crate::display::DisplayState;
|
||||||
use rayhunter::analysis::analyzer::EventType;
|
use rayhunter::analysis::analyzer::EventType;
|
||||||
|
|
||||||
@@ -176,7 +176,7 @@ pub fn update_ui(
|
|||||||
) {
|
) {
|
||||||
static IMAGE_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/images/");
|
static IMAGE_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/images/");
|
||||||
let display_level = config.ui_level;
|
let display_level = config.ui_level;
|
||||||
if display_level == 0 {
|
if display_level == UiLevel::Invisible {
|
||||||
info!("Invisible mode, not spawning UI.");
|
info!("Invisible mode, not spawning UI.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -187,14 +187,14 @@ pub fn update_ui(
|
|||||||
task_tracker.spawn(async move {
|
task_tracker.spawn(async move {
|
||||||
// this feels wrong, is there a more rusty way to do this?
|
// this feels wrong, is there a more rusty way to do this?
|
||||||
let mut img: Option<&[u8]> = None;
|
let mut img: Option<&[u8]> = None;
|
||||||
if display_level == 2 {
|
if display_level == UiLevel::Demo {
|
||||||
img = Some(
|
img = Some(
|
||||||
IMAGE_DIR
|
IMAGE_DIR
|
||||||
.get_file("orca.gif")
|
.get_file("orca.gif")
|
||||||
.expect("failed to read orca.gif")
|
.expect("failed to read orca.gif")
|
||||||
.contents(),
|
.contents(),
|
||||||
);
|
);
|
||||||
} else if display_level == 3 {
|
} else if display_level == UiLevel::EffLogo {
|
||||||
img = Some(
|
img = Some(
|
||||||
IMAGE_DIR
|
IMAGE_DIR
|
||||||
.get_file("eff.png")
|
.get_file("eff.png")
|
||||||
@@ -217,20 +217,19 @@ pub fn update_ui(
|
|||||||
|
|
||||||
let mut status_bar_height = 2;
|
let mut status_bar_height = 2;
|
||||||
match display_level {
|
match display_level {
|
||||||
2 => fb.draw_gif(img.unwrap()).await,
|
UiLevel::Demo => fb.draw_gif(img.unwrap()).await,
|
||||||
3 => fb.draw_img(img.unwrap()).await,
|
UiLevel::EffLogo => fb.draw_img(img.unwrap()).await,
|
||||||
4 => {
|
UiLevel::HighVisibility => {
|
||||||
status_bar_height = fb.dimensions().height;
|
status_bar_height = fb.dimensions().height;
|
||||||
}
|
}
|
||||||
128 => {
|
UiLevel::TransFlag => {
|
||||||
fb.draw_line(Color::Cyan, 128).await;
|
fb.draw_line(Color::Cyan, 128).await;
|
||||||
fb.draw_line(Color::Pink, 102).await;
|
fb.draw_line(Color::Pink, 102).await;
|
||||||
fb.draw_line(Color::White, 76).await;
|
fb.draw_line(Color::White, 76).await;
|
||||||
fb.draw_line(Color::Pink, 50).await;
|
fb.draw_line(Color::Pink, 50).await;
|
||||||
fb.draw_line(Color::Cyan, 25).await;
|
fb.draw_line(Color::Cyan, 25).await;
|
||||||
}
|
}
|
||||||
// this branch is for ui_level 1, which is also the default if an
|
// UiLevel::Subtle (1) and anything else: just the status bar line
|
||||||
// unknown value is used
|
|
||||||
_ => {}
|
_ => {}
|
||||||
};
|
};
|
||||||
let (color, pattern) = display_style;
|
let (color, pattern) = display_style;
|
||||||
|
|||||||
@@ -7,10 +7,18 @@ use crate::config;
|
|||||||
use crate::display::DisplayState;
|
use crate::display::DisplayState;
|
||||||
|
|
||||||
pub fn update_ui(
|
pub fn update_ui(
|
||||||
_task_tracker: &TaskTracker,
|
task_tracker: &TaskTracker,
|
||||||
_config: &config::Config,
|
_config: &config::Config,
|
||||||
_shutdown_token: CancellationToken,
|
shutdown_token: CancellationToken,
|
||||||
_ui_update_rx: Receiver<DisplayState>,
|
mut ui_update_rx: Receiver<DisplayState>,
|
||||||
) {
|
) {
|
||||||
info!("Headless mode, not spawning UI.");
|
info!("Headless mode, not spawning UI.");
|
||||||
|
task_tracker.spawn(async move {
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = shutdown_token.cancelled() => break,
|
||||||
|
_ = ui_update_rx.recv() => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use tokio_util::task::TaskTracker;
|
|||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::config;
|
use crate::config::{self, UiLevel};
|
||||||
use crate::display::DisplayState;
|
use crate::display::DisplayState;
|
||||||
|
|
||||||
macro_rules! led {
|
macro_rules! led {
|
||||||
@@ -31,7 +31,7 @@ pub fn update_ui(
|
|||||||
mut ui_update_rx: mpsc::Receiver<DisplayState>,
|
mut ui_update_rx: mpsc::Receiver<DisplayState>,
|
||||||
) {
|
) {
|
||||||
let mut invisible: bool = false;
|
let mut invisible: bool = false;
|
||||||
if config.ui_level == 0 {
|
if config.ui_level == UiLevel::Invisible {
|
||||||
info!("Invisible mode, not spawning UI.");
|
info!("Invisible mode, not spawning UI.");
|
||||||
invisible = true;
|
invisible = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use tokio::sync::mpsc::Receiver;
|
|||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
use tokio_util::task::TaskTracker;
|
use tokio_util::task::TaskTracker;
|
||||||
|
|
||||||
use crate::config;
|
use crate::config::{self, UiLevel};
|
||||||
use crate::display::{DisplayState, tplink_framebuffer, tplink_onebit};
|
use crate::display::{DisplayState, tplink_framebuffer, tplink_onebit};
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -15,7 +15,7 @@ pub fn update_ui(
|
|||||||
ui_update_rx: Receiver<DisplayState>,
|
ui_update_rx: Receiver<DisplayState>,
|
||||||
) {
|
) {
|
||||||
let display_level = config.ui_level;
|
let display_level = config.ui_level;
|
||||||
if display_level == 0 {
|
if display_level == UiLevel::Invisible {
|
||||||
info!("Invisible mode, not spawning UI.");
|
info!("Invisible mode, not spawning UI.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/// Display module for the TP-Link M7350 oled one-bit display.
|
/// Display module for the TP-Link M7350 oled one-bit display.
|
||||||
///
|
///
|
||||||
/// https://github.com/m0veax/tplink_m7350/tree/main/oled
|
/// https://github.com/m0veax/tplink_m7350/tree/main/oled
|
||||||
use crate::config;
|
use crate::config::{self, UiLevel};
|
||||||
use crate::display::DisplayState;
|
use crate::display::DisplayState;
|
||||||
|
|
||||||
use log::{error, info};
|
use log::{error, info};
|
||||||
@@ -115,7 +115,7 @@ pub fn update_ui(
|
|||||||
mut ui_update_rx: Receiver<DisplayState>,
|
mut ui_update_rx: Receiver<DisplayState>,
|
||||||
) {
|
) {
|
||||||
let display_level = config.ui_level;
|
let display_level = config.ui_level;
|
||||||
if display_level == 0 {
|
if display_level == UiLevel::Invisible {
|
||||||
info!("Invisible mode, not spawning UI.");
|
info!("Invisible mode, not spawning UI.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +140,7 @@ pub fn update_ui(
|
|||||||
|
|
||||||
// we write the status every second because it may have been overwritten through menu
|
// we write the status every second because it may have been overwritten through menu
|
||||||
// navigation.
|
// navigation.
|
||||||
if display_level != 0
|
if display_level != UiLevel::Invisible
|
||||||
&& let Err(e) = tokio::fs::write(OLED_PATH, pixels).await
|
&& let Err(e) = tokio::fs::write(OLED_PATH, pixels).await
|
||||||
{
|
{
|
||||||
error!("failed to write to display: {e}");
|
error!("failed to write to display: {e}");
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use tokio_util::task::TaskTracker;
|
|||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::config;
|
use crate::config::{self, UiLevel};
|
||||||
use crate::display::DisplayState;
|
use crate::display::DisplayState;
|
||||||
|
|
||||||
macro_rules! led {
|
macro_rules! led {
|
||||||
@@ -31,7 +31,7 @@ pub fn update_ui(
|
|||||||
mut ui_update_rx: mpsc::Receiver<DisplayState>,
|
mut ui_update_rx: mpsc::Receiver<DisplayState>,
|
||||||
) {
|
) {
|
||||||
let mut invisible: bool = false;
|
let mut invisible: bool = false;
|
||||||
if config.ui_level == 0 {
|
if config.ui_level == UiLevel::Invisible {
|
||||||
info!("Invisible mode, not spawning UI.");
|
info!("Invisible mode, not spawning UI.");
|
||||||
invisible = true;
|
invisible = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ use tokio::sync::mpsc::Sender;
|
|||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
use tokio_util::task::TaskTracker;
|
use tokio_util::task::TaskTracker;
|
||||||
|
|
||||||
use crate::config;
|
use crate::config::{self, KeyInputMode};
|
||||||
use crate::diag::DiagDeviceCtrlMessage;
|
use crate::diag::DiagDeviceCtrlMessage;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -23,7 +23,7 @@ pub fn run_key_input_thread(
|
|||||||
diag_tx: Sender<DiagDeviceCtrlMessage>,
|
diag_tx: Sender<DiagDeviceCtrlMessage>,
|
||||||
cancellation_token: CancellationToken,
|
cancellation_token: CancellationToken,
|
||||||
) {
|
) {
|
||||||
if config.key_input_mode == 0 {
|
if config.key_input_mode == KeyInputMode::Disabled {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -5,12 +5,14 @@ pub mod crypto_provider;
|
|||||||
pub mod diag;
|
pub mod diag;
|
||||||
pub mod display;
|
pub mod display;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod gps;
|
||||||
pub mod key_input;
|
pub mod key_input;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
pub mod pcap;
|
pub mod pcap;
|
||||||
pub mod qmdl_store;
|
pub mod qmdl_store;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
pub mod stats;
|
pub mod stats;
|
||||||
|
pub mod update;
|
||||||
pub mod webdav;
|
pub mod webdav;
|
||||||
|
|
||||||
#[cfg(feature = "apidocs")]
|
#[cfg(feature = "apidocs")]
|
||||||
@@ -33,6 +35,7 @@ use utoipa::OpenApi;
|
|||||||
server::get_zip,
|
server::get_zip,
|
||||||
stats::get_system_stats,
|
stats::get_system_stats,
|
||||||
stats::get_qmdl_manifest,
|
stats::get_qmdl_manifest,
|
||||||
|
stats::get_update_status,
|
||||||
stats::get_log,
|
stats::get_log,
|
||||||
diag::start_recording,
|
diag::start_recording,
|
||||||
diag::stop_recording,
|
diag::stop_recording,
|
||||||
@@ -46,7 +49,9 @@ use utoipa::OpenApi;
|
|||||||
server::test_notification,
|
server::test_notification,
|
||||||
server::get_time,
|
server::get_time,
|
||||||
server::set_time_offset,
|
server::set_time_offset,
|
||||||
server::debug_set_display_state
|
server::debug_set_display_state,
|
||||||
|
gps::post_gps,
|
||||||
|
gps::get_gps
|
||||||
),
|
),
|
||||||
servers(
|
servers(
|
||||||
(
|
(
|
||||||
|
|||||||
+49
-3
@@ -5,21 +5,24 @@ mod crypto_provider;
|
|||||||
mod diag;
|
mod diag;
|
||||||
mod display;
|
mod display;
|
||||||
mod error;
|
mod error;
|
||||||
|
mod gps;
|
||||||
mod key_input;
|
mod key_input;
|
||||||
mod notifications;
|
mod notifications;
|
||||||
mod pcap;
|
mod pcap;
|
||||||
mod qmdl_store;
|
mod qmdl_store;
|
||||||
mod server;
|
mod server;
|
||||||
mod stats;
|
mod stats;
|
||||||
|
mod update;
|
||||||
mod webdav;
|
mod webdav;
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::battery::run_battery_notification_worker;
|
use crate::battery::run_battery_notification_worker;
|
||||||
use crate::config::{parse_args, parse_config};
|
use crate::config::{GpsMode, parse_args, parse_config};
|
||||||
use crate::diag::run_diag_read_thread;
|
use crate::diag::run_diag_read_thread;
|
||||||
use crate::error::RayhunterError;
|
use crate::error::RayhunterError;
|
||||||
|
use crate::gps::{get_gps, post_gps};
|
||||||
use crate::notifications::{NotificationService, run_notification_worker};
|
use crate::notifications::{NotificationService, run_notification_worker};
|
||||||
use crate::pcap::get_pcap;
|
use crate::pcap::get_pcap;
|
||||||
use crate::qmdl_store::RecordingStore;
|
use crate::qmdl_store::RecordingStore;
|
||||||
@@ -27,7 +30,8 @@ use crate::server::{
|
|||||||
ServerState, debug_set_display_state, get_config, get_qmdl, get_time, get_wifi_status, get_zip,
|
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,
|
scan_wifi, serve_static, set_config, set_time_offset, test_notification,
|
||||||
};
|
};
|
||||||
use crate::stats::{get_qmdl_manifest, get_system_stats};
|
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 crate::webdav::run_webdav_upload_worker;
|
||||||
use wifi_station::WifiStatus;
|
use wifi_station::WifiStatus;
|
||||||
|
|
||||||
@@ -41,7 +45,7 @@ use diag::{
|
|||||||
DiagDeviceCtrlMessage, delete_all_recordings, delete_recording, get_analysis_report,
|
DiagDeviceCtrlMessage, delete_all_recordings, delete_recording, get_analysis_report,
|
||||||
start_recording, stop_recording,
|
start_recording, stop_recording,
|
||||||
};
|
};
|
||||||
use log::{error, info};
|
use log::{error, info, warn};
|
||||||
use qmdl_store::RecordingStoreError;
|
use qmdl_store::RecordingStoreError;
|
||||||
use rayhunter::Device;
|
use rayhunter::Device;
|
||||||
use stats::get_log;
|
use stats::get_log;
|
||||||
@@ -61,6 +65,7 @@ fn get_router() -> AppRouter {
|
|||||||
.route("/api/qmdl/{name}", get(get_qmdl))
|
.route("/api/qmdl/{name}", get(get_qmdl))
|
||||||
.route("/api/zip/{name}", get(get_zip))
|
.route("/api/zip/{name}", get(get_zip))
|
||||||
.route("/api/system-stats", get(get_system_stats))
|
.route("/api/system-stats", get(get_system_stats))
|
||||||
|
.route("/api/update-status", get(get_update_status))
|
||||||
.route("/api/qmdl-manifest", get(get_qmdl_manifest))
|
.route("/api/qmdl-manifest", get(get_qmdl_manifest))
|
||||||
.route("/api/log", get(get_log))
|
.route("/api/log", get(get_log))
|
||||||
.route("/api/start-recording", post(start_recording))
|
.route("/api/start-recording", post(start_recording))
|
||||||
@@ -78,6 +83,8 @@ fn get_router() -> AppRouter {
|
|||||||
.route("/api/time", get(get_time))
|
.route("/api/time", get(get_time))
|
||||||
.route("/api/time-offset", post(set_time_offset))
|
.route("/api/time-offset", post(set_time_offset))
|
||||||
.route("/api/debug/display-state", post(debug_set_display_state))
|
.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("/", get(|| async { Redirect::permanent("/index.html") }))
|
||||||
.route("/{*path}", get(serve_static))
|
.route("/{*path}", get(serve_static))
|
||||||
}
|
}
|
||||||
@@ -213,9 +220,14 @@ async fn run_with_config(
|
|||||||
let _shutdown_guard = shutdown_token.clone().drop_guard();
|
let _shutdown_guard = shutdown_token.clone().drop_guard();
|
||||||
|
|
||||||
let notification_service = NotificationService::new(config.ntfy_url.clone());
|
let notification_service = NotificationService::new(config.ntfy_url.clone());
|
||||||
|
let update_status_lock = Arc::new(RwLock::new(UpdateStatus::default()));
|
||||||
|
|
||||||
if !config.debug_mode {
|
if !config.debug_mode {
|
||||||
info!("Starting Diag Thread");
|
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(
|
run_diag_read_thread(
|
||||||
&task_tracker,
|
&task_tracker,
|
||||||
config.device.clone(),
|
config.device.clone(),
|
||||||
@@ -228,6 +240,8 @@ async fn run_with_config(
|
|||||||
notification_service.new_handler(),
|
notification_service.new_handler(),
|
||||||
config.min_space_to_start_recording_mb,
|
config.min_space_to_start_recording_mb,
|
||||||
config.min_space_to_continue_recording_mb,
|
config.min_space_to_continue_recording_mb,
|
||||||
|
config.gps_mode,
|
||||||
|
gps_fixed_coords,
|
||||||
);
|
);
|
||||||
info!("Starting UI");
|
info!("Starting UI");
|
||||||
|
|
||||||
@@ -248,6 +262,16 @@ async fn run_with_config(
|
|||||||
diag_tx.clone(),
|
diag_tx.clone(),
|
||||||
shutdown_token.clone(),
|
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 analysis_status_lock = Arc::new(RwLock::new(analysis_status));
|
let analysis_status_lock = Arc::new(RwLock::new(analysis_status));
|
||||||
@@ -267,12 +291,14 @@ async fn run_with_config(
|
|||||||
analysis_tx.clone(),
|
analysis_tx.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if !config.debug_mode {
|
||||||
run_battery_notification_worker(
|
run_battery_notification_worker(
|
||||||
&task_tracker,
|
&task_tracker,
|
||||||
config.device.clone(),
|
config.device.clone(),
|
||||||
notification_service.new_handler(),
|
notification_service.new_handler(),
|
||||||
shutdown_token.clone(),
|
shutdown_token.clone(),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
run_notification_worker(
|
run_notification_worker(
|
||||||
&task_tracker,
|
&task_tracker,
|
||||||
@@ -281,12 +307,14 @@ async fn run_with_config(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let wifi_status = Arc::new(RwLock::new(WifiStatus::default()));
|
let wifi_status = Arc::new(RwLock::new(WifiStatus::default()));
|
||||||
|
if !config.debug_mode {
|
||||||
wifi_station::run_wifi_client(
|
wifi_station::run_wifi_client(
|
||||||
&task_tracker,
|
&task_tracker,
|
||||||
&config.wifi_config(),
|
&config.wifi_config(),
|
||||||
shutdown_token.clone(),
|
shutdown_token.clone(),
|
||||||
wifi_status.clone(),
|
wifi_status.clone(),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if !config.webdav.url.trim().is_empty() {
|
if !config.webdav.url.trim().is_empty() {
|
||||||
run_webdav_upload_worker(
|
run_webdav_upload_worker(
|
||||||
@@ -296,6 +324,22 @@ async fn run_with_config(
|
|||||||
config.webdav.clone().into(),
|
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 {
|
let state = Arc::new(ServerState {
|
||||||
config_path: args.config_path.clone(),
|
config_path: args.config_path.clone(),
|
||||||
@@ -308,6 +352,8 @@ async fn run_with_config(
|
|||||||
ui_update_sender: Some(ui_update_tx),
|
ui_update_sender: Some(ui_update_tx),
|
||||||
wifi_status,
|
wifi_status,
|
||||||
wifi_scan_lock: tokio::sync::Mutex::new(()),
|
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, shutdown_token.clone()).await;
|
run_server(&task_tracker, state, shutdown_token.clone()).await;
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ pub enum NotificationError {
|
|||||||
pub enum NotificationType {
|
pub enum NotificationType {
|
||||||
Warning,
|
Warning,
|
||||||
LowBattery,
|
LowBattery,
|
||||||
|
Update,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Notification {
|
pub struct Notification {
|
||||||
@@ -290,7 +291,7 @@ mod tests {
|
|||||||
println!("error = {:?}", reqwest_error);
|
println!("error = {:?}", reqwest_error);
|
||||||
assert!(reqwest_error.is_timeout());
|
assert!(reqwest_error.is_timeout());
|
||||||
}
|
}
|
||||||
_ => assert!(false),
|
_ => unreachable!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+144
-25
@@ -1,5 +1,8 @@
|
|||||||
|
use crate::gps::{GpsRecord, load_gps_records};
|
||||||
|
use crate::qmdl_store::FileKind;
|
||||||
use crate::server::ServerState;
|
use crate::server::ServerState;
|
||||||
|
|
||||||
|
use crate::config::GpsMode;
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, State};
|
||||||
@@ -7,17 +10,13 @@ use axum::http::StatusCode;
|
|||||||
use axum::http::header::CONTENT_TYPE;
|
use axum::http::header::CONTENT_TYPE;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use log::error;
|
use log::error;
|
||||||
use rayhunter::diag::DataType;
|
use rayhunter::gsmtap::parser as gsmtap_parser;
|
||||||
use rayhunter::gsmtap_parser;
|
use rayhunter::pcap::{GpsPoint, GsmtapPcapWriter};
|
||||||
use rayhunter::pcap::GsmtapPcapWriter;
|
use rayhunter::qmdl::QmdlMessageReader;
|
||||||
use rayhunter::qmdl::QmdlReader;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::io::{AsyncRead, AsyncWrite, duplex};
|
use tokio::io::{AsyncRead, AsyncSeek, AsyncWrite, duplex};
|
||||||
use tokio_util::io::ReaderStream;
|
use tokio_util::io::ReaderStream;
|
||||||
|
|
||||||
// Streams a pcap file chunk-by-chunk to the client by reading the QMDL data
|
|
||||||
// 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(
|
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/api/pcap/{name}",
|
path = "/api/pcap/{name}",
|
||||||
@@ -51,17 +50,20 @@ pub async fn get_pcap(
|
|||||||
"QMDL file is empty, try again in a bit!".to_string(),
|
"QMDL file is empty, try again in a bit!".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let qmdl_size_bytes = entry.qmdl_size_bytes;
|
|
||||||
let qmdl_file = qmdl_store
|
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:?}")))?
|
||||||
|
.ok_or((StatusCode::NOT_FOUND, "QMDL file not found".to_string()))?;
|
||||||
|
let qmdl_reader = QmdlMessageReader::new(qmdl_file)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?;
|
||||||
// the QMDL reader should stop at the last successfully written data chunk
|
|
||||||
// (entry.size_bytes)
|
|
||||||
let (reader, writer) = duplex(1024);
|
let (reader, writer) = duplex(1024);
|
||||||
|
let gps_records = load_gps_records_for_entry(&state, entry_index).await;
|
||||||
|
drop(qmdl_store);
|
||||||
|
|
||||||
tokio::spawn(async move {
|
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_reader, gps_records).await {
|
||||||
error!("failed to generate PCAP: {e:?}");
|
error!("failed to generate PCAP: {e:?}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -71,38 +73,155 @@ pub async fn get_pcap(
|
|||||||
Ok((headers, body).into_response())
|
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>(
|
pub async fn generate_pcap_data<R, W>(
|
||||||
writer: W,
|
writer: W,
|
||||||
qmdl_file: R,
|
mut reader: QmdlMessageReader<R>,
|
||||||
qmdl_size_bytes: usize,
|
gps_records: Vec<GpsRecord>,
|
||||||
) -> Result<(), Error>
|
) -> Result<(), Error>
|
||||||
where
|
where
|
||||||
W: AsyncWrite + Unpin + Send,
|
W: AsyncWrite + Unpin + Send,
|
||||||
R: AsyncRead + Unpin,
|
R: AsyncRead + AsyncSeek + Unpin,
|
||||||
{
|
{
|
||||||
let mut pcap_writer = GsmtapPcapWriter::new(writer).await?;
|
let mut pcap_writer = GsmtapPcapWriter::new(writer).await?;
|
||||||
pcap_writer.write_iface_header().await?;
|
pcap_writer.write_iface_header().await?;
|
||||||
|
|
||||||
let mut reader = QmdlReader::new(qmdl_file, Some(qmdl_size_bytes));
|
while let Some(maybe_msg) = reader.get_next_message().await? {
|
||||||
while let Some(container) = reader.get_next_messages_container().await? {
|
|
||||||
if container.data_type != DataType::UserSpace {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for maybe_msg in container.into_messages() {
|
|
||||||
match maybe_msg {
|
match maybe_msg {
|
||||||
Ok(msg) => {
|
Ok(msg) => {
|
||||||
let maybe_gsmtap_msg = gsmtap_parser::parse(msg)?;
|
let maybe_gsmtap_msg = gsmtap_parser::parse(msg)?;
|
||||||
if let Some((timestamp, gsmtap_msg)) = maybe_gsmtap_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
|
pcap_writer
|
||||||
.write_gsmtap_message(gsmtap_msg, timestamp)
|
.write_gsmtap_message(gsmtap_msg, timestamp, gps.as_ref())
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => error!("error parsing message: {e:?}"),
|
Err(e) => error!("error parsing message: {e:?}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+141
-72
@@ -1,7 +1,9 @@
|
|||||||
|
use std::fmt::Display;
|
||||||
use std::io::{self, ErrorKind};
|
use std::io::{self, ErrorKind};
|
||||||
use std::os::unix::fs::MetadataExt;
|
use std::os::unix::fs::MetadataExt;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::config::GpsMode;
|
||||||
use chrono::{DateTime, Local, TimeDelta};
|
use chrono::{DateTime, Local, TimeDelta};
|
||||||
use log::{info, warn};
|
use log::{info, warn};
|
||||||
use rayhunter::util::RuntimeMetadata;
|
use rayhunter::util::RuntimeMetadata;
|
||||||
@@ -22,6 +24,8 @@ pub enum RecordingStoreError {
|
|||||||
CreateFileError(tokio::io::Error),
|
CreateFileError(tokio::io::Error),
|
||||||
#[error("Couldn't read file: {0}")]
|
#[error("Couldn't read file: {0}")]
|
||||||
ReadFileError(tokio::io::Error),
|
ReadFileError(tokio::io::Error),
|
||||||
|
#[error("Couldn't write file: {0}")]
|
||||||
|
WriteFileError(tokio::io::Error),
|
||||||
#[error("Couldn't delete file: {0}")]
|
#[error("Couldn't delete file: {0}")]
|
||||||
DeleteFileError(tokio::io::Error),
|
DeleteFileError(tokio::io::Error),
|
||||||
#[error("Couldn't open directory at path: {0}")]
|
#[error("Couldn't open directory at path: {0}")]
|
||||||
@@ -32,6 +36,54 @@ pub enum RecordingStoreError {
|
|||||||
WriteManifestError(tokio::io::Error),
|
WriteManifestError(tokio::io::Error),
|
||||||
#[error("Couldn't parse QMDL store manifest file: {0}")]
|
#[error("Couldn't parse QMDL store manifest file: {0}")]
|
||||||
ParseManifestError(toml::de::Error),
|
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, qmdl_compressed: bool) -> String {
|
||||||
|
match self {
|
||||||
|
FileKind::Qmdl if qmdl_compressed => format!("{}.qmdl.gz", entry_name),
|
||||||
|
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,
|
||||||
|
qmdl_compressed: bool,
|
||||||
|
) -> PathBuf {
|
||||||
|
base_path
|
||||||
|
.as_ref()
|
||||||
|
.join(self.get_filename(entry_name, qmdl_compressed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
pub struct RecordingStore {
|
||||||
@@ -57,7 +109,6 @@ pub struct ManifestEntry {
|
|||||||
/// The system time when the last message was recorded to the file
|
/// The system time when the last message was recorded to the file
|
||||||
#[cfg_attr(feature = "apidocs", schema(value_type = String))]
|
#[cfg_attr(feature = "apidocs", schema(value_type = String))]
|
||||||
pub last_message_time: Option<DateTime<Local>>,
|
pub last_message_time: Option<DateTime<Local>>,
|
||||||
/// The size of the QMDL file in bytes
|
|
||||||
pub qmdl_size_bytes: usize,
|
pub qmdl_size_bytes: usize,
|
||||||
/// The rayhunter daemon version which generated the file
|
/// The rayhunter daemon version which generated the file
|
||||||
pub rayhunter_version: Option<String>,
|
pub rayhunter_version: Option<String>,
|
||||||
@@ -70,10 +121,14 @@ pub struct ManifestEntry {
|
|||||||
/// When the manifest was uploaded to a WebDAV server
|
/// When the manifest was uploaded to a WebDAV server
|
||||||
#[cfg_attr(feature = "apidocs", schema(value_type = String))]
|
#[cfg_attr(feature = "apidocs", schema(value_type = String))]
|
||||||
pub upload_time: Option<DateTime<Local>>,
|
pub upload_time: Option<DateTime<Local>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub gps_mode: Option<GpsMode>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub compressed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ManifestEntry {
|
impl ManifestEntry {
|
||||||
fn new() -> Self {
|
fn new(gps_mode: GpsMode) -> Self {
|
||||||
let now = rayhunter::clock::get_adjusted_now();
|
let now = rayhunter::clock::get_adjusted_now();
|
||||||
let metadata = RuntimeMetadata::new();
|
let metadata = RuntimeMetadata::new();
|
||||||
ManifestEntry {
|
ManifestEntry {
|
||||||
@@ -86,19 +141,13 @@ impl ManifestEntry {
|
|||||||
arch: Some(metadata.arch),
|
arch: Some(metadata.arch),
|
||||||
stop_reason: None,
|
stop_reason: None,
|
||||||
upload_time: None,
|
upload_time: None,
|
||||||
|
gps_mode: Some(gps_mode),
|
||||||
|
compressed: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_qmdl_filepath<P: AsRef<Path>>(&self, path: P) -> PathBuf {
|
pub fn get_filepath<P: AsRef<Path>>(&self, file_kind: FileKind, path: P) -> PathBuf {
|
||||||
let mut filepath = path.as_ref().join(&self.name);
|
file_kind.get_filepath(&self.name, path, self.compressed)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,8 +206,9 @@ impl RecordingStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Does a best-effort attempt to recover the manifest from a directory of
|
// Does a best-effort attempt to recover the manifest from a directory of
|
||||||
// QMDL files. We expect these files to be named like "<timestamp>.qmdl",
|
// QMDL files. We expect these files to be named like "<timestamp>.qmdl"
|
||||||
// and skip any files which don't match that pattern.
|
// or "<timestamp>.qmdl.gz", and skip any files which don't match that
|
||||||
|
// pattern.
|
||||||
pub async fn recover<P>(path: P) -> Result<Self, RecordingStoreError>
|
pub async fn recover<P>(path: P) -> Result<Self, RecordingStoreError>
|
||||||
where
|
where
|
||||||
P: AsRef<Path>,
|
P: AsRef<Path>,
|
||||||
@@ -178,11 +228,14 @@ impl RecordingStore {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
if !filename.ends_with(".qmdl") {
|
let (stem, compressed) = if filename.ends_with(".qmdl") {
|
||||||
|
(filename.trim_end_matches(".qmdl"), false)
|
||||||
|
} else if filename.ends_with(".qmdl.gz") {
|
||||||
|
(filename.trim_end_matches(".qmdl.gz"), true)
|
||||||
|
} else {
|
||||||
continue;
|
continue;
|
||||||
}
|
};
|
||||||
|
|
||||||
let stem = filename.trim_end_matches(".qmdl");
|
|
||||||
let Ok(start_timestamp) = stem.parse::<i64>() else {
|
let Ok(start_timestamp) = stem.parse::<i64>() else {
|
||||||
warn!("QMDL file has invalid name {os_filename:?}, skipping");
|
warn!("QMDL file has invalid name {os_filename:?}, skipping");
|
||||||
continue;
|
continue;
|
||||||
@@ -209,6 +262,7 @@ impl RecordingStore {
|
|||||||
info!("successfully recovered QMDL entry {os_filename:?}!");
|
info!("successfully recovered QMDL entry {os_filename:?}!");
|
||||||
manifest_entries.push(ManifestEntry {
|
manifest_entries.push(ManifestEntry {
|
||||||
name: stem.to_string(),
|
name: stem.to_string(),
|
||||||
|
compressed,
|
||||||
start_time: start_time.into(),
|
start_time: start_time.into(),
|
||||||
last_message_time: Some(last_message_time.into()),
|
last_message_time: Some(last_message_time.into()),
|
||||||
qmdl_size_bytes: metadata.size() as usize,
|
qmdl_size_bytes: metadata.size() as usize,
|
||||||
@@ -217,6 +271,7 @@ impl RecordingStore {
|
|||||||
arch: None,
|
arch: None,
|
||||||
stop_reason: None,
|
stop_reason: None,
|
||||||
upload_time: None,
|
upload_time: None,
|
||||||
|
gps_mode: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,43 +304,63 @@ impl RecordingStore {
|
|||||||
// Closes the current entry (if needed), creates a new entry based on the
|
// 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
|
// current time, and updates the manifest. Returns a tuple of the entry's
|
||||||
// newly created QMDL file and analysis file.
|
// 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 we've already got an entry open, close it
|
||||||
if self.current_entry.is_some() {
|
if self.current_entry.is_some() {
|
||||||
self.close_current_entry().await?;
|
self.close_current_entry().await?;
|
||||||
}
|
}
|
||||||
let new_entry = ManifestEntry::new();
|
let new_entry = ManifestEntry::new(gps_mode);
|
||||||
let qmdl_filepath = new_entry.get_qmdl_filepath(&self.path);
|
let qmdl_filepath = new_entry.get_filepath(FileKind::Qmdl, &self.path);
|
||||||
let qmdl_file = File::create(&qmdl_filepath)
|
let qmdl_file = File::create(&qmdl_filepath)
|
||||||
.await
|
.await
|
||||||
.map_err(RecordingStoreError::CreateFileError)?;
|
.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)
|
let analysis_file = File::create(&analysis_filepath)
|
||||||
.await
|
.await
|
||||||
.map_err(RecordingStoreError::CreateFileError)?;
|
.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.manifest.entries.push(new_entry);
|
||||||
self.current_entry = Some(self.manifest.entries.len() - 1);
|
self.current_entry = Some(self.manifest.entries.len() - 1);
|
||||||
self.write_manifest().await?;
|
self.write_manifest().await?;
|
||||||
Ok((qmdl_file, analysis_file))
|
Ok((qmdl_file, analysis_file))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the corresponding QMDL file for a given entry
|
pub async fn open_file(
|
||||||
pub async fn open_entry_qmdl(&self, entry_index: usize) -> Result<File, RecordingStoreError> {
|
|
||||||
let entry = &self.manifest.entries[entry_index];
|
|
||||||
File::open(entry.get_qmdl_filepath(&self.path))
|
|
||||||
.await
|
|
||||||
.map_err(RecordingStoreError::ReadFileError)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns the corresponding QMDL file for a given entry
|
|
||||||
pub async fn open_entry_analysis(
|
|
||||||
&self,
|
&self,
|
||||||
entry_index: usize,
|
entry_index: usize,
|
||||||
) -> Result<File, RecordingStoreError> {
|
file_kind: FileKind,
|
||||||
|
) -> Result<Option<File>, RecordingStoreError> {
|
||||||
let entry = &self.manifest.entries[entry_index];
|
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, entry.compressed);
|
||||||
|
|
||||||
|
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
|
.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(
|
pub async fn clear_and_open_entry_analysis(
|
||||||
@@ -296,7 +371,7 @@ impl RecordingStore {
|
|||||||
let file = OpenOptions::new()
|
let file = OpenOptions::new()
|
||||||
.write(true)
|
.write(true)
|
||||||
.truncate(true)
|
.truncate(true)
|
||||||
.open(entry.get_analysis_filepath(&self.path))
|
.open(entry.get_filepath(FileKind::Analysis, &self.path))
|
||||||
.await
|
.await
|
||||||
.map_err(RecordingStoreError::ReadFileError)?;
|
.map_err(RecordingStoreError::ReadFileError)?;
|
||||||
Ok(file)
|
Ok(file)
|
||||||
@@ -313,12 +388,14 @@ impl RecordingStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sets the given entry's size and updates the last_message_time to now, updating the manifest
|
// Sets the current entry's size and updates the last_message_time to now, updating the manifest
|
||||||
pub async fn update_entry_qmdl_size(
|
pub async fn update_current_entry_qmdl_size(
|
||||||
&mut self,
|
&mut self,
|
||||||
entry_index: usize,
|
|
||||||
size_bytes: usize,
|
size_bytes: usize,
|
||||||
) -> Result<(), RecordingStoreError> {
|
) -> Result<(), RecordingStoreError> {
|
||||||
|
let Some(entry_index) = self.current_entry else {
|
||||||
|
return Err(RecordingStoreError::NoCurrentEntry);
|
||||||
|
};
|
||||||
self.manifest.entries[entry_index].qmdl_size_bytes = size_bytes;
|
self.manifest.entries[entry_index].qmdl_size_bytes = size_bytes;
|
||||||
self.manifest.entries[entry_index].last_message_time =
|
self.manifest.entries[entry_index].last_message_time =
|
||||||
Some(rayhunter::clock::get_adjusted_now());
|
Some(rayhunter::clock::get_adjusted_now());
|
||||||
@@ -434,14 +511,17 @@ impl RecordingStore {
|
|||||||
};
|
};
|
||||||
let entry_to_delete = self.manifest.entries.remove(entry_to_delete_idx);
|
let entry_to_delete = self.manifest.entries.remove(entry_to_delete_idx);
|
||||||
self.write_manifest().await?;
|
self.write_manifest().await?;
|
||||||
let qmdl_filepath = entry_to_delete.get_qmdl_filepath(&self.path);
|
|
||||||
let analysis_filepath = entry_to_delete.get_analysis_filepath(&self.path);
|
for &file_kind in FileKind::ALL {
|
||||||
remove_file_if_exists(&qmdl_filepath)
|
let filepath = file_kind.get_filepath(
|
||||||
.await
|
&entry_to_delete.name,
|
||||||
.map_err(RecordingStoreError::DeleteFileError)?;
|
&self.path,
|
||||||
remove_file_if_exists(&analysis_filepath)
|
entry_to_delete.compressed,
|
||||||
|
);
|
||||||
|
remove_file_if_exists(&filepath)
|
||||||
.await
|
.await
|
||||||
.map_err(RecordingStoreError::DeleteFileError)?;
|
.map_err(RecordingStoreError::DeleteFileError)?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,20 +532,16 @@ impl RecordingStore {
|
|||||||
|
|
||||||
let mut keep = Vec::new();
|
let mut keep = Vec::new();
|
||||||
|
|
||||||
for entry in &self.manifest.entries {
|
'entries: for entry in &self.manifest.entries {
|
||||||
let qmdl_filepath = entry.get_qmdl_filepath(&self.path);
|
for &file_kind in FileKind::ALL {
|
||||||
let analysis_filepath = entry.get_analysis_filepath(&self.path);
|
let filepath = file_kind.get_filepath(&entry.name, &self.path, entry.compressed);
|
||||||
|
if let Err(e) = remove_file_if_exists(&filepath).await {
|
||||||
if let Err(e) = remove_file_if_exists(&qmdl_filepath).await {
|
log::warn!("failed to remove {filepath:?}: {e:?}");
|
||||||
log::warn!("failed to remove {qmdl_filepath:?}: {e:?}");
|
// Some error happened with deleting this entry, abort and go to the next one.
|
||||||
|
// Also *keep* the manifest entry.
|
||||||
keep.push(true);
|
keep.push(true);
|
||||||
continue;
|
continue 'entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(e) = remove_file_if_exists(&analysis_filepath).await {
|
|
||||||
log::warn!("failed to remove {analysis_filepath:?}: {e:?}");
|
|
||||||
keep.push(true);
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
keep.push(false);
|
keep.push(false);
|
||||||
@@ -508,7 +584,7 @@ mod tests {
|
|||||||
async fn test_creating_updating_and_closing_entries() {
|
async fn test_creating_updating_and_closing_entries() {
|
||||||
let dir = make_temp_dir();
|
let dir = make_temp_dir();
|
||||||
let mut store = RecordingStore::create(dir.path()).await.unwrap();
|
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 entry_index = store.current_entry.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
RecordingStore::read_manifest(dir.path()).await.unwrap(),
|
RecordingStore::read_manifest(dir.path()).await.unwrap(),
|
||||||
@@ -520,10 +596,7 @@ mod tests {
|
|||||||
.is_none()
|
.is_none()
|
||||||
);
|
);
|
||||||
|
|
||||||
store
|
store.update_current_entry_qmdl_size(1000).await.unwrap();
|
||||||
.update_entry_qmdl_size(entry_index, 1000)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let (entry_index, entry) = store
|
let (entry_index, entry) = store
|
||||||
.entry_for_name(&store.manifest.entries[entry_index].name)
|
.entry_for_name(&store.manifest.entries[entry_index].name)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -545,12 +618,8 @@ mod tests {
|
|||||||
async fn test_create_on_existing_store() {
|
async fn test_create_on_existing_store() {
|
||||||
let dir = make_temp_dir();
|
let dir = make_temp_dir();
|
||||||
let mut store = RecordingStore::create(dir.path()).await.unwrap();
|
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_current_entry_qmdl_size(1000).await.unwrap();
|
||||||
store
|
|
||||||
.update_entry_qmdl_size(entry_index, 1000)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let store = RecordingStore::create(dir.path()).await.unwrap();
|
let store = RecordingStore::create(dir.path()).await.unwrap();
|
||||||
assert_eq!(store.manifest.entries.len(), 0);
|
assert_eq!(store.manifest.entries.len(), 0);
|
||||||
}
|
}
|
||||||
@@ -559,9 +628,9 @@ mod tests {
|
|||||||
async fn test_repeated_new_entries() {
|
async fn test_repeated_new_entries() {
|
||||||
let dir = make_temp_dir();
|
let dir = make_temp_dir();
|
||||||
let mut store = RecordingStore::create(dir.path()).await.unwrap();
|
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 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();
|
let new_entry_index = store.current_entry.unwrap();
|
||||||
assert_ne!(entry_index, new_entry_index);
|
assert_ne!(entry_index, new_entry_index);
|
||||||
assert_eq!(store.manifest.entries.len(), 2);
|
assert_eq!(store.manifest.entries.len(), 2);
|
||||||
@@ -571,7 +640,7 @@ mod tests {
|
|||||||
async fn test_delete_all_entries() {
|
async fn test_delete_all_entries() {
|
||||||
let dir = make_temp_dir();
|
let dir = make_temp_dir();
|
||||||
let mut store = RecordingStore::create(dir.path()).await.unwrap();
|
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());
|
assert!(store.current_entry.is_some());
|
||||||
|
|
||||||
store.delete_all_entries().await.unwrap();
|
store.delete_all_entries().await.unwrap();
|
||||||
@@ -587,7 +656,7 @@ mod tests {
|
|||||||
async fn test_mark_entry_as_uploaded_sets_time_and_persists() {
|
async fn test_mark_entry_as_uploaded_sets_time_and_persists() {
|
||||||
let dir = make_temp_dir();
|
let dir = make_temp_dir();
|
||||||
let mut store = RecordingStore::create(dir.path()).await.unwrap();
|
let mut store = RecordingStore::create(dir.path()).await.unwrap();
|
||||||
let _ = store.new_entry().await.unwrap();
|
let _ = store.new_entry(GpsMode::Disabled).await.unwrap();
|
||||||
let name = store.manifest.entries[0].name.clone();
|
let name = store.manifest.entries[0].name.clone();
|
||||||
store.close_current_entry().await.unwrap();
|
store.close_current_entry().await.unwrap();
|
||||||
|
|
||||||
@@ -618,7 +687,7 @@ mod tests {
|
|||||||
let mut store = RecordingStore::create(dir.path()).await.unwrap();
|
let mut store = RecordingStore::create(dir.path()).await.unwrap();
|
||||||
|
|
||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
let _ = store.new_entry().await.unwrap();
|
let _ = store.new_entry(GpsMode::Disabled).await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
store.manifest.entries[0].name = "entry-0".to_owned();
|
store.manifest.entries[0].name = "entry-0".to_owned();
|
||||||
|
|||||||
+145
-56
@@ -6,28 +6,35 @@ use axum::Json;
|
|||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::extract::Path;
|
use axum::extract::Path;
|
||||||
use axum::extract::State;
|
use axum::extract::State;
|
||||||
use axum::http::header::{self, CONTENT_LENGTH, CONTENT_TYPE};
|
use axum::http::header::{self, CONTENT_TYPE};
|
||||||
use axum::http::{HeaderValue, StatusCode};
|
use axum::http::{HeaderValue, StatusCode};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use chrono::{DateTime, Local};
|
use chrono::{DateTime, Local};
|
||||||
|
use futures::TryStreamExt;
|
||||||
use log::{error, warn};
|
use log::{error, warn};
|
||||||
|
use rayhunter::qmdl::QmdlMessageReader;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::pin::pin;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::fs::write;
|
use tokio::fs::write;
|
||||||
use tokio::io::{AsyncReadExt, copy, duplex};
|
use tokio::io::copy;
|
||||||
|
use tokio::io::duplex;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use tokio::sync::mpsc::Sender;
|
use tokio::sync::mpsc::Sender;
|
||||||
|
use tokio_util::compat::FuturesAsyncReadCompatExt;
|
||||||
use tokio_util::compat::FuturesAsyncWriteCompatExt;
|
use tokio_util::compat::FuturesAsyncWriteCompatExt;
|
||||||
use tokio_util::io::ReaderStream;
|
use tokio_util::io::ReaderStream;
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
use crate::analysis::{AnalysisCtrlMessage, AnalysisStatus};
|
use crate::analysis::{AnalysisCtrlMessage, AnalysisStatus};
|
||||||
use crate::config::Config;
|
use crate::config::{Config, GpsMode};
|
||||||
use crate::diag::DiagDeviceCtrlMessage;
|
use crate::diag::DiagDeviceCtrlMessage;
|
||||||
use crate::display::DisplayState;
|
use crate::display::DisplayState;
|
||||||
|
use crate::gps::GpsData;
|
||||||
use crate::notifications::DEFAULT_NOTIFICATION_TIMEOUT;
|
use crate::notifications::DEFAULT_NOTIFICATION_TIMEOUT;
|
||||||
use crate::pcap::generate_pcap_data;
|
use crate::pcap::{generate_pcap_data, load_gps_records_for_entry};
|
||||||
use crate::qmdl_store::RecordingStore;
|
use crate::qmdl_store::{FileKind, RecordingStore};
|
||||||
|
use crate::update::UpdateStatus;
|
||||||
|
|
||||||
pub struct ServerState {
|
pub struct ServerState {
|
||||||
pub config_path: String,
|
pub config_path: String,
|
||||||
@@ -40,6 +47,8 @@ pub struct ServerState {
|
|||||||
pub ui_update_sender: Option<Sender<DisplayState>>,
|
pub ui_update_sender: Option<Sender<DisplayState>>,
|
||||||
pub wifi_status: Arc<RwLock<wifi_station::WifiStatus>>,
|
pub wifi_status: Arc<RwLock<wifi_station::WifiStatus>>,
|
||||||
pub wifi_scan_lock: tokio::sync::Mutex<()>,
|
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(
|
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||||
@@ -63,27 +72,29 @@ pub async fn get_qmdl(
|
|||||||
) -> Result<Response, (StatusCode, String)> {
|
) -> Result<Response, (StatusCode, String)> {
|
||||||
let qmdl_idx = qmdl_name.trim_end_matches(".qmdl");
|
let qmdl_idx = qmdl_name.trim_end_matches(".qmdl");
|
||||||
let qmdl_store = state.qmdl_store_lock.read().await;
|
let qmdl_store = state.qmdl_store_lock.read().await;
|
||||||
let (entry_index, entry) = qmdl_store.entry_for_name(qmdl_idx).ok_or((
|
let (entry_index, _) = qmdl_store.entry_for_name(qmdl_idx).ok_or((
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
format!("couldn't find qmdl file with name {qmdl_idx}"),
|
format!("couldn't find qmdl file with name {qmdl_idx}"),
|
||||||
))?;
|
))?;
|
||||||
let qmdl_file = qmdl_store
|
let qmdl_file = qmdl_store
|
||||||
.open_entry_qmdl(entry_index)
|
.open_file(entry_index, FileKind::Qmdl)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| {
|
.map_err(|err| {
|
||||||
(
|
(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
format!("error opening QMDL file: {err}"),
|
format!("error opening QMDL file: {err}"),
|
||||||
)
|
)
|
||||||
|
})?
|
||||||
|
.ok_or((StatusCode::NOT_FOUND, "QMDL file not found".to_string()))?;
|
||||||
|
let qmdl_reader = QmdlMessageReader::new(qmdl_file).await.map_err(|err| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("error reading QMDL file: {err}"),
|
||||||
|
)
|
||||||
})?;
|
})?;
|
||||||
let limited_qmdl_file = qmdl_file.take(entry.qmdl_size_bytes as u64);
|
|
||||||
let qmdl_stream = ReaderStream::new(limited_qmdl_file);
|
|
||||||
|
|
||||||
let headers = [
|
let headers = [(CONTENT_TYPE, "application/octet-stream")];
|
||||||
(CONTENT_TYPE, "application/octet-stream"),
|
let body = Body::from_stream(qmdl_reader.into_qmdl_stream());
|
||||||
(CONTENT_LENGTH, &entry.qmdl_size_bytes.to_string()),
|
|
||||||
];
|
|
||||||
let body = Body::from_stream(qmdl_stream);
|
|
||||||
Ok((headers, body).into_response())
|
Ok((headers, body).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,9 +123,9 @@ pub async fn serve_static(
|
|||||||
"index.html" => (
|
"index.html" => (
|
||||||
[
|
[
|
||||||
(header::CONTENT_TYPE, HeaderValue::from_static("text/html")),
|
(header::CONTENT_TYPE, HeaderValue::from_static("text/html")),
|
||||||
(header::CONTENT_ENCODING, HeaderValue::from_static("br")),
|
(header::CONTENT_ENCODING, HeaderValue::from_static("gzip")),
|
||||||
],
|
],
|
||||||
include_bytes!("../web/build/index.html.br"),
|
include_bytes!("../web/build/index.html.gz"),
|
||||||
)
|
)
|
||||||
.into_response(),
|
.into_response(),
|
||||||
path => {
|
path => {
|
||||||
@@ -160,8 +171,12 @@ pub async fn get_config(
|
|||||||
))]
|
))]
|
||||||
pub async fn set_config(
|
pub async fn set_config(
|
||||||
State(state): State<Arc<ServerState>>,
|
State(state): State<Arc<ServerState>>,
|
||||||
Json(config): Json<Config>,
|
Json(mut config): Json<Config>,
|
||||||
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
) -> Result<(StatusCode, String), (StatusCode, String)> {
|
||||||
|
if config.gps_mode != GpsMode::Fixed {
|
||||||
|
config.gps_fixed_latitude = None;
|
||||||
|
config.gps_fixed_longitude = None;
|
||||||
|
}
|
||||||
let mut config_to_write = config.clone();
|
let mut config_to_write = config.clone();
|
||||||
config_to_write.wifi_ssid = None;
|
config_to_write.wifi_ssid = None;
|
||||||
config_to_write.wifi_password = None;
|
config_to_write.wifi_password = None;
|
||||||
@@ -325,7 +340,7 @@ pub async fn get_zip(
|
|||||||
Path(entry_name): Path<String>,
|
Path(entry_name): Path<String>,
|
||||||
) -> Result<Response, (StatusCode, String)> {
|
) -> Result<Response, (StatusCode, String)> {
|
||||||
let qmdl_idx = entry_name.trim_end_matches(".zip").to_owned();
|
let qmdl_idx = entry_name.trim_end_matches(".zip").to_owned();
|
||||||
let (entry_index, qmdl_size_bytes) = {
|
let entry_index = {
|
||||||
let qmdl_store = state.qmdl_store_lock.read().await;
|
let qmdl_store = state.qmdl_store_lock.read().await;
|
||||||
let (entry_index, entry) = qmdl_store.entry_for_name(&qmdl_idx).ok_or((
|
let (entry_index, entry) = qmdl_store.entry_for_name(&qmdl_idx).ok_or((
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
@@ -339,10 +354,11 @@ pub async fn get_zip(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
(entry_index, entry.qmdl_size_bytes)
|
entry_index
|
||||||
};
|
};
|
||||||
|
|
||||||
let qmdl_store_lock = state.qmdl_store_lock.clone();
|
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);
|
let (reader, writer) = duplex(8192);
|
||||||
|
|
||||||
@@ -350,24 +366,51 @@ pub async fn get_zip(
|
|||||||
let result: Result<(), Error> = async {
|
let result: Result<(), Error> = async {
|
||||||
let mut zip = ZipFileWriter::with_tokio(writer);
|
let mut zip = ZipFileWriter::with_tokio(writer);
|
||||||
|
|
||||||
// Add QMDL file
|
const EXCLUDED_FROM_ZIP: &[FileKind] = &[FileKind::Analysis];
|
||||||
{
|
|
||||||
let entry =
|
// Add stored files
|
||||||
ZipEntryBuilder::new(format!("{qmdl_idx}.qmdl").into(), Compression::Stored);
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* `qmdl_compressed` is always false here because even if the
|
||||||
|
* QMDL was already compressed, we decompress it before zipping.
|
||||||
|
* This is for two reasons
|
||||||
|
* 1. If this is the current entry, it's still being written and
|
||||||
|
* lacks a GZIP footer. If we zipped up this partial .gz
|
||||||
|
* file, some software might consider it damaged and refuse to
|
||||||
|
* extract it.
|
||||||
|
* 2. Zipping an already-GZIP'd file is redundant and
|
||||||
|
* inconvenient for the user.
|
||||||
|
*/
|
||||||
|
let zip_entry = ZipEntryBuilder::new(
|
||||||
|
file_kind.get_filename(&qmdl_idx, false).into(),
|
||||||
|
Compression::Stored,
|
||||||
|
);
|
||||||
// FuturesAsyncWriteCompatExt::compat_write because async-zip's entrystream does
|
// FuturesAsyncWriteCompatExt::compat_write because async-zip's entrystream does
|
||||||
// not impl tokio's AsyncWrite, but only future's AsyncWrite. This can be removed
|
// 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.
|
// 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 entry_writer = zip.write_entry_stream(zip_entry).await?.compat_write();
|
||||||
|
|
||||||
let mut qmdl_file = {
|
if file_kind == FileKind::Qmdl {
|
||||||
let qmdl_store = qmdl_store_lock.read().await;
|
let reader = QmdlMessageReader::new(&mut file).await?;
|
||||||
qmdl_store
|
let stream = reader.into_qmdl_stream();
|
||||||
.open_entry_qmdl(entry_index)
|
let mut reader = pin!(stream.into_async_read().compat());
|
||||||
.await?
|
copy(&mut reader, &mut entry_writer).await?;
|
||||||
.take(qmdl_size_bytes as u64)
|
} else {
|
||||||
};
|
copy(&mut file, &mut entry_writer).await?;
|
||||||
|
}
|
||||||
copy(&mut qmdl_file, &mut entry_writer).await?;
|
|
||||||
entry_writer.into_inner().close().await?;
|
entry_writer.into_inner().close().await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,13 +423,14 @@ pub async fn get_zip(
|
|||||||
let qmdl_file_for_pcap = {
|
let qmdl_file_for_pcap = {
|
||||||
let qmdl_store = qmdl_store_lock.read().await;
|
let qmdl_store = qmdl_store_lock.read().await;
|
||||||
qmdl_store
|
qmdl_store
|
||||||
.open_entry_qmdl(entry_index)
|
.open_file(entry_index, FileKind::Qmdl)
|
||||||
.await?
|
.await?
|
||||||
.take(qmdl_size_bytes as u64)
|
.ok_or_else(|| anyhow::anyhow!("QMDL file not found"))?
|
||||||
};
|
};
|
||||||
|
let qmdl_reader = QmdlMessageReader::new(qmdl_file_for_pcap).await?;
|
||||||
|
|
||||||
if let Err(e) =
|
if let Err(e) =
|
||||||
generate_pcap_data(&mut entry_writer, qmdl_file_for_pcap, qmdl_size_bytes).await
|
generate_pcap_data(&mut entry_writer, qmdl_reader, gps_records).await
|
||||||
{
|
{
|
||||||
// if we fail to generate the PCAP file, we should still continue and give the
|
// if we fail to generate the PCAP file, we should still continue and give the
|
||||||
// user the QMDL.
|
// user the QMDL.
|
||||||
@@ -500,9 +544,17 @@ pub async fn debug_set_display_state(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::config::GpsMode;
|
||||||
use async_zip::base::read::mem::ZipFileReader;
|
use async_zip::base::read::mem::ZipFileReader;
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, State};
|
||||||
|
use futures::AsyncReadExt;
|
||||||
|
use rayhunter::{
|
||||||
|
diag::{DataType, HdlcEncapsulatedMessage, Message, MessagesContainer},
|
||||||
|
qmdl::{QmdlMessageReader, QmdlWriter},
|
||||||
|
};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
async fn create_test_qmdl_store() -> (TempDir, Arc<RwLock<crate::qmdl_store::RecordingStore>>) {
|
async fn create_test_qmdl_store() -> (TempDir, Arc<RwLock<crate::qmdl_store::RecordingStore>>) {
|
||||||
@@ -516,24 +568,25 @@ mod tests {
|
|||||||
|
|
||||||
async fn create_test_entry_with_data(
|
async fn create_test_entry_with_data(
|
||||||
store_lock: &Arc<RwLock<crate::qmdl_store::RecordingStore>>,
|
store_lock: &Arc<RwLock<crate::qmdl_store::RecordingStore>>,
|
||||||
test_data: &[u8],
|
test_data: &MessagesContainer,
|
||||||
) -> String {
|
) -> String {
|
||||||
let entry_name = {
|
let entry_name = {
|
||||||
let mut store = store_lock.write().await;
|
let mut store = store_lock.write().await;
|
||||||
let (mut qmdl_file, _analysis_file) = store.new_entry().await.unwrap();
|
let (mut qmdl_gz_file, _analysis_file) =
|
||||||
|
store.new_entry(GpsMode::Disabled).await.unwrap();
|
||||||
|
|
||||||
if !test_data.is_empty() {
|
let mut writer = QmdlWriter::new(&mut qmdl_gz_file);
|
||||||
use tokio::io::AsyncWriteExt;
|
writer.write_container(test_data).await.unwrap();
|
||||||
qmdl_file.write_all(test_data).await.unwrap();
|
writer.close().await.unwrap();
|
||||||
qmdl_file.flush().await.unwrap();
|
|
||||||
}
|
let qmdl_file_size = qmdl_gz_file.metadata().await.unwrap().len() as usize;
|
||||||
|
|
||||||
let current_entry = store.current_entry.unwrap();
|
let current_entry = store.current_entry.unwrap();
|
||||||
let entry = &store.manifest.entries[current_entry];
|
let entry = &store.manifest.entries[current_entry];
|
||||||
let entry_name = entry.name.clone();
|
let entry_name = entry.name.clone();
|
||||||
|
|
||||||
store
|
store
|
||||||
.update_entry_qmdl_size(current_entry, test_data.len())
|
.update_current_entry_qmdl_size(qmdl_file_size)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
entry_name
|
entry_name
|
||||||
@@ -566,20 +619,37 @@ mod tests {
|
|||||||
ui_update_sender: None,
|
ui_update_sender: None,
|
||||||
wifi_status: Arc::new(RwLock::new(wifi_station::WifiStatus::default())),
|
wifi_status: Arc::new(RwLock::new(wifi_station::WifiStatus::default())),
|
||||||
wifi_scan_lock: tokio::sync::Mutex::new(()),
|
wifi_scan_lock: tokio::sync::Mutex::new(()),
|
||||||
|
gps_state: Arc::new(RwLock::new(None)),
|
||||||
|
update_status_lock: Arc::new(RwLock::new(UpdateStatus::default())),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// valid HDLC encapsulated diag message generated from
|
||||||
|
// rayhunter::diag::test::get_test_message
|
||||||
|
fn create_test_container() -> MessagesContainer {
|
||||||
|
MessagesContainer {
|
||||||
|
data_type: DataType::UserSpace,
|
||||||
|
num_messages: 1,
|
||||||
|
messages: vec![HdlcEncapsulatedMessage {
|
||||||
|
len: 39,
|
||||||
|
data: vec![
|
||||||
|
16, 0, 32, 0, 32, 0, 192, 176, 26, 165, 245, 135, 118, 35, 2, 1, 20, 14, 48, 0,
|
||||||
|
160, 0, 2, 8, 0, 0, 217, 15, 5, 0, 0, 0, 0, 1, 0, 10, 13, 196, 126,
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_get_zip_success() {
|
async fn test_get_zip_success() {
|
||||||
let (_temp_dir, store_lock) = create_test_qmdl_store().await;
|
let (_temp_dir, store_lock) = create_test_qmdl_store().await;
|
||||||
let test_qmdl_data = vec![0x7E, 0x00, 0x00, 0x00, 0x10, 0x00, 0x7E];
|
let test_qmdl_data = create_test_container();
|
||||||
let entry_name = create_test_entry_with_data(&store_lock, &test_qmdl_data).await;
|
let entry_name = create_test_entry_with_data(&store_lock, &test_qmdl_data).await;
|
||||||
let state = create_test_server_state(store_lock);
|
let state = create_test_server_state(store_lock);
|
||||||
|
|
||||||
let result = get_zip(State(state), Path(entry_name.clone())).await;
|
let response = get_zip(State(state), Path(entry_name.clone()))
|
||||||
|
.await
|
||||||
assert!(result.is_ok());
|
.unwrap();
|
||||||
let response = result.unwrap();
|
|
||||||
|
|
||||||
let headers = response.headers();
|
let headers = response.headers();
|
||||||
assert_eq!(headers.get("content-type").unwrap(), "application/zip");
|
assert_eq!(headers.get("content-type").unwrap(), "application/zip");
|
||||||
@@ -588,17 +658,36 @@ mod tests {
|
|||||||
let body_bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
|
let body_bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
|
||||||
|
|
||||||
let zip_reader = ZipFileReader::new(body_bytes.to_vec()).await.unwrap();
|
let zip_reader = ZipFileReader::new(body_bytes.to_vec()).await.unwrap();
|
||||||
|
let zip_reader_file = zip_reader.file();
|
||||||
let filenames = zip_reader
|
let filenames: Vec<String> = zip_reader_file
|
||||||
.file()
|
|
||||||
.entries()
|
.entries()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|entry| entry.filename().as_str().unwrap().to_owned())
|
.map(|entry| entry.filename().as_str().unwrap().to_string())
|
||||||
.collect::<Vec<String>>();
|
.collect();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
filenames,
|
filenames,
|
||||||
vec![format!("{entry_name}.qmdl"), format!("{entry_name}.pcapng"),]
|
vec![
|
||||||
|
format!("{entry_name}.qmdl"),
|
||||||
|
format!("{entry_name}-gps.ndjson"),
|
||||||
|
format!("{entry_name}.pcapng"),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut qmdl_body = Vec::with_capacity(128);
|
||||||
|
zip_reader
|
||||||
|
.reader_without_entry(0)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.read_to_end(&mut qmdl_body)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut qmdl_reader = QmdlMessageReader::new(Cursor::new(qmdl_body))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let expected_message = Message::from_hdlc(&test_qmdl_data.messages[0].data).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
qmdl_reader.get_next_message().await.unwrap(),
|
||||||
|
Some(Ok(expected_message)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use std::sync::Arc;
|
|||||||
use crate::battery::get_battery_status;
|
use crate::battery::get_battery_status;
|
||||||
use crate::error::RayhunterError;
|
use crate::error::RayhunterError;
|
||||||
use crate::server::ServerState;
|
use crate::server::ServerState;
|
||||||
|
use crate::update::UpdateStatus;
|
||||||
use crate::{battery::BatteryState, qmdl_store::ManifestEntry};
|
use crate::{battery::BatteryState, qmdl_store::ManifestEntry};
|
||||||
|
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
@@ -220,6 +221,20 @@ pub async fn get_qmdl_manifest(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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(
|
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/api/log",
|
path = "/api/log",
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
-52
@@ -1,18 +1,17 @@
|
|||||||
use std::fmt::Display;
|
|
||||||
use std::{sync::Arc, time::Duration};
|
use std::{sync::Arc, time::Duration};
|
||||||
|
|
||||||
use chrono::TimeDelta;
|
use chrono::TimeDelta;
|
||||||
|
use futures::future::join_all;
|
||||||
use log::{info, warn};
|
use log::{info, warn};
|
||||||
use reqwest::header::{CONTENT_LENGTH, CONTENT_TYPE};
|
use reqwest::header::{CONTENT_LENGTH, CONTENT_TYPE};
|
||||||
use reqwest::{Body, Client, Response};
|
use reqwest::{Body, Client, Response};
|
||||||
use tokio::fs::File;
|
use tokio::fs::File;
|
||||||
use tokio::join;
|
|
||||||
use tokio::{select, sync::RwLock, time};
|
use tokio::{select, sync::RwLock, time};
|
||||||
use tokio_util::io::ReaderStream;
|
use tokio_util::io::ReaderStream;
|
||||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||||
|
|
||||||
use crate::config::WebdavConfig;
|
use crate::config::WebdavConfig;
|
||||||
use crate::qmdl_store::RecordingStore;
|
use crate::qmdl_store::{FileKind, RecordingStore};
|
||||||
|
|
||||||
pub struct WebdavUploadWorkerConfig {
|
pub struct WebdavUploadWorkerConfig {
|
||||||
poll_interval: Duration,
|
poll_interval: Duration,
|
||||||
@@ -38,29 +37,6 @@ impl From<WebdavConfig> for WebdavUploadWorkerConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum FileKind {
|
|
||||||
Analysis,
|
|
||||||
Qmdl,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FileKind {
|
|
||||||
fn as_extension(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
FileKind::Analysis => ".ndjson",
|
|
||||||
FileKind::Qmdl => ".qmdl",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Display for FileKind {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
FileKind::Analysis => write!(f, "analysis"),
|
|
||||||
FileKind::Qmdl => write!(f, "QMDL"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct WebDavClient {
|
struct WebDavClient {
|
||||||
client: Client,
|
client: Client,
|
||||||
@@ -126,23 +102,24 @@ async fn try_upload_entry(
|
|||||||
shutdown_token: CancellationToken,
|
shutdown_token: CancellationToken,
|
||||||
) -> Option<()> {
|
) -> Option<()> {
|
||||||
let read_lock = store.read().await;
|
let read_lock = store.read().await;
|
||||||
let entry_idx = read_lock.entry_for_name(&entry_name)?.0;
|
let (entry_idx, entry) = read_lock.entry_for_name(&entry_name)?;
|
||||||
let file = match file_kind {
|
let compressed = entry.compressed;
|
||||||
FileKind::Analysis => read_lock.open_entry_analysis(entry_idx).await,
|
let file = read_lock.open_file(entry_idx, file_kind).await;
|
||||||
FileKind::Qmdl => read_lock.open_entry_qmdl(entry_idx).await,
|
|
||||||
};
|
|
||||||
drop(read_lock);
|
drop(read_lock);
|
||||||
|
|
||||||
let Ok(file) = file.map_err(|err| {
|
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!(
|
warn!(
|
||||||
"Unable to open entry: {} {} file: {:?}",
|
"Unable to open entry: {} {} file: {:?}",
|
||||||
entry_name, file_kind, err
|
entry_name, file_kind, err
|
||||||
)
|
);
|
||||||
}) else {
|
|
||||||
return None;
|
return None;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let file_name = format!("{}{}", entry_name, file_kind.as_extension());
|
let file_name = file_kind.get_filename(&entry_name, compressed);
|
||||||
|
|
||||||
let res = select! {
|
let res = select! {
|
||||||
_ = shutdown_token.cancelled() => {
|
_ = shutdown_token.cancelled() => {
|
||||||
@@ -205,24 +182,23 @@ pub fn run_webdav_upload_worker(
|
|||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
|
|
||||||
let (Some(()), Some(())) = join!(
|
let upload_futures: Vec<_> = FileKind::ALL
|
||||||
|
.iter()
|
||||||
|
.map(|&file_kind| {
|
||||||
try_upload_entry(
|
try_upload_entry(
|
||||||
webdav_client.clone(),
|
webdav_client.clone(),
|
||||||
qmdl_store_lock.clone(),
|
qmdl_store_lock.clone(),
|
||||||
unuploaded_entry.clone(),
|
unuploaded_entry.clone(),
|
||||||
FileKind::Qmdl,
|
file_kind,
|
||||||
shutdown_token.clone(),
|
shutdown_token.clone(),
|
||||||
),
|
)
|
||||||
try_upload_entry(
|
})
|
||||||
webdav_client.clone(),
|
.collect();
|
||||||
qmdl_store_lock.clone(),
|
|
||||||
unuploaded_entry.clone(),
|
let results = join_all(upload_futures).await;
|
||||||
FileKind::Analysis,
|
if !results.iter().all(|r| r.is_some()) {
|
||||||
shutdown_token.clone()
|
|
||||||
),
|
|
||||||
) else {
|
|
||||||
break;
|
break;
|
||||||
};
|
}
|
||||||
|
|
||||||
if config.delete_on_upload {
|
if config.delete_on_upload {
|
||||||
match qmdl_store_lock.write().await.delete_entry(&unuploaded_entry).await {
|
match qmdl_store_lock.write().await.delete_entry(&unuploaded_entry).await {
|
||||||
@@ -245,6 +221,7 @@ pub fn run_webdav_upload_worker(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::config::GpsMode;
|
||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
Router,
|
||||||
body::Bytes,
|
body::Bytes,
|
||||||
@@ -313,7 +290,7 @@ mod tests {
|
|||||||
dir: &std::path::Path,
|
dir: &std::path::Path,
|
||||||
) -> (Arc<RwLock<RecordingStore>>, String) {
|
) -> (Arc<RwLock<RecordingStore>>, String) {
|
||||||
let mut store = RecordingStore::create(dir).await.unwrap();
|
let mut store = RecordingStore::create(dir).await.unwrap();
|
||||||
let (mut qmdl_file, mut analysis_file) = store.new_entry().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.write_all(b"fake qmdl payload").await.unwrap();
|
||||||
qmdl_file.flush().await.unwrap();
|
qmdl_file.flush().await.unwrap();
|
||||||
analysis_file
|
analysis_file
|
||||||
@@ -323,7 +300,7 @@ mod tests {
|
|||||||
analysis_file.flush().await.unwrap();
|
analysis_file.flush().await.unwrap();
|
||||||
let entry_index = store.current_entry.unwrap();
|
let entry_index = store.current_entry.unwrap();
|
||||||
let name = store.manifest.entries[entry_index].name.clone();
|
let name = store.manifest.entries[entry_index].name.clone();
|
||||||
store.update_entry_qmdl_size(entry_index, 17).await.unwrap();
|
store.update_current_entry_qmdl_size(17).await.unwrap();
|
||||||
store.close_current_entry().await.unwrap();
|
store.close_current_entry().await.unwrap();
|
||||||
(Arc::new(RwLock::new(store)), name)
|
(Arc::new(RwLock::new(store)), name)
|
||||||
}
|
}
|
||||||
@@ -353,12 +330,14 @@ mod tests {
|
|||||||
cleanup_worker(shutdown, tracker).await;
|
cleanup_worker(shutdown, tracker).await;
|
||||||
|
|
||||||
let recorded = captured.lock().await;
|
let recorded = captured.lock().await;
|
||||||
assert_eq!(recorded.len(), 2);
|
assert_eq!(recorded.len(), 3);
|
||||||
let paths: Vec<&str> = recorded.iter().map(|r| r.path.as_str()).collect();
|
let paths: Vec<&str> = recorded.iter().map(|r| r.path.as_str()).collect();
|
||||||
let qmdl_path = format!("dav/{}.qmdl", entry_name);
|
let qmdl_path = format!("dav/{}.qmdl.gz", entry_name);
|
||||||
let ndjson_path = format!("dav/{}.ndjson", 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(&qmdl_path.as_str()));
|
||||||
assert!(paths.contains(&ndjson_path.as_str()));
|
assert!(paths.contains(&ndjson_path.as_str()));
|
||||||
|
assert!(paths.contains(&gps_path.as_str()));
|
||||||
for put in recorded.iter() {
|
for put in recorded.iter() {
|
||||||
assert_eq!(put.auth.as_deref(), Some("Basic dXNlcjpwYXNzd29yZA=="));
|
assert_eq!(put.auth.as_deref(), Some("Basic dXNlcjpwYXNzd29yZA=="));
|
||||||
}
|
}
|
||||||
@@ -407,7 +386,7 @@ mod tests {
|
|||||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||||
cleanup_worker(shutdown, tracker).await;
|
cleanup_worker(shutdown, tracker).await;
|
||||||
|
|
||||||
assert_eq!(captured.lock().await.len(), 2);
|
assert_eq!(captured.lock().await.len(), 3);
|
||||||
|
|
||||||
let store_read = store.read().await;
|
let store_read = store.read().await;
|
||||||
assert!(store_read.entry_for_name(&entry_name).is_none());
|
assert!(store_read.entry_for_name(&entry_name).is_none());
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
Generated
+102
-126
@@ -11,7 +11,7 @@
|
|||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@sveltejs/adapter-auto": "^7.0.1",
|
"@sveltejs/adapter-auto": "^7.0.1",
|
||||||
"@sveltejs/adapter-static": "^3.0.5",
|
"@sveltejs/adapter-static": "^3.0.5",
|
||||||
"@sveltejs/kit": "^2.58.0",
|
"@sveltejs/kit": "^2.60.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"@types/eslint": "^9.6.0",
|
"@types/eslint": "^9.6.0",
|
||||||
@@ -22,12 +22,12 @@
|
|||||||
"globals": "^17.5.0",
|
"globals": "^17.5.0",
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
"prettier-plugin-svelte": "^3.5.1",
|
"prettier-plugin-svelte": "^3.5.1",
|
||||||
"svelte": "^5.55.5",
|
"svelte": "^5.55.7",
|
||||||
"svelte-check": "^4.4.6",
|
"svelte-check": "^4.4.6",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.2",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.59.0",
|
"typescript-eslint": "^8.59.0",
|
||||||
"vite": "^8.0.10",
|
"vite": "^8.0.16",
|
||||||
"vitest": "^4.1.5"
|
"vitest": "^4.1.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -329,9 +329,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-project/types": {
|
"node_modules/@oxc-project/types": {
|
||||||
"version": "0.127.0",
|
"version": "0.133.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
|
||||||
"integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==",
|
"integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
@@ -346,9 +346,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
|
||||||
"integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==",
|
"integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -363,9 +363,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz",
|
||||||
"integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==",
|
"integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -380,9 +380,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-darwin-x64": {
|
"node_modules/@rolldown/binding-darwin-x64": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz",
|
||||||
"integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==",
|
"integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -397,9 +397,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz",
|
||||||
"integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==",
|
"integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -414,9 +414,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz",
|
||||||
"integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==",
|
"integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -431,9 +431,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz",
|
||||||
"integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==",
|
"integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -451,9 +451,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz",
|
||||||
"integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==",
|
"integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -471,9 +471,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz",
|
||||||
"integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==",
|
"integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -491,9 +491,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz",
|
||||||
"integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==",
|
"integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -511,9 +511,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz",
|
||||||
"integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==",
|
"integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -531,9 +531,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz",
|
||||||
"integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==",
|
"integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -551,9 +551,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz",
|
||||||
"integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==",
|
"integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -568,9 +568,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz",
|
||||||
"integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==",
|
"integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"wasm32"
|
"wasm32"
|
||||||
],
|
],
|
||||||
@@ -587,9 +587,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
|
||||||
"integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==",
|
"integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -604,9 +604,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz",
|
||||||
"integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==",
|
"integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -621,9 +621,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||||
"integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==",
|
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -665,9 +665,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@sveltejs/kit": {
|
"node_modules/@sveltejs/kit": {
|
||||||
"version": "2.58.0",
|
"version": "2.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.58.0.tgz",
|
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.60.1.tgz",
|
||||||
"integrity": "sha512-kT9GCN8yJTkCK1W+Gi/bvGooWAM7y7WXP+yd+rf6QOIjyoK1ERPrMwSufXJUNu2pMWIqruhFvmz+LbOqsEmKmA==",
|
"integrity": "sha512-mQjlkNo+rJvpln7V2IGY2j99BqhcFbS4UN0AQNKNYfhBAFZTuCDAdW3a1sgf330mvtNvsBXn3HpAhcmvdJTcIQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -676,7 +676,7 @@
|
|||||||
"@types/cookie": "^0.6.0",
|
"@types/cookie": "^0.6.0",
|
||||||
"acorn": "^8.14.1",
|
"acorn": "^8.14.1",
|
||||||
"cookie": "^0.6.0",
|
"cookie": "^0.6.0",
|
||||||
"devalue": "^5.6.4",
|
"devalue": "^5.8.1",
|
||||||
"esm-env": "^1.2.2",
|
"esm-env": "^1.2.2",
|
||||||
"kleur": "^4.1.5",
|
"kleur": "^4.1.5",
|
||||||
"magic-string": "^0.30.5",
|
"magic-string": "^0.30.5",
|
||||||
@@ -859,9 +859,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -879,9 +876,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -899,9 +893,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -919,9 +910,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1658,9 +1646,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/devalue": {
|
"node_modules/devalue": {
|
||||||
"version": "5.7.1",
|
"version": "5.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
|
||||||
"integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==",
|
"integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -2371,9 +2359,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2395,9 +2380,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2419,9 +2401,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2443,9 +2422,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2588,9 +2564,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.12",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2722,9 +2698,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.10",
|
"version": "8.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||||
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
|
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2742,7 +2718,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.12",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
},
|
},
|
||||||
@@ -2920,14 +2896,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rolldown": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
|
||||||
"integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==",
|
"integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oxc-project/types": "=0.127.0",
|
"@oxc-project/types": "=0.133.0",
|
||||||
"@rolldown/pluginutils": "1.0.0-rc.17"
|
"@rolldown/pluginutils": "^1.0.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"rolldown": "bin/cli.mjs"
|
"rolldown": "bin/cli.mjs"
|
||||||
@@ -2936,21 +2912,21 @@
|
|||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rolldown/binding-android-arm64": "1.0.0-rc.17",
|
"@rolldown/binding-android-arm64": "1.0.3",
|
||||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.17",
|
"@rolldown/binding-darwin-arm64": "1.0.3",
|
||||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.17",
|
"@rolldown/binding-darwin-x64": "1.0.3",
|
||||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.17",
|
"@rolldown/binding-freebsd-x64": "1.0.3",
|
||||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17",
|
"@rolldown/binding-linux-arm-gnueabihf": "1.0.3",
|
||||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17",
|
"@rolldown/binding-linux-arm64-gnu": "1.0.3",
|
||||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17",
|
"@rolldown/binding-linux-arm64-musl": "1.0.3",
|
||||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17",
|
"@rolldown/binding-linux-ppc64-gnu": "1.0.3",
|
||||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17",
|
"@rolldown/binding-linux-s390x-gnu": "1.0.3",
|
||||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17",
|
"@rolldown/binding-linux-x64-gnu": "1.0.3",
|
||||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.17",
|
"@rolldown/binding-linux-x64-musl": "1.0.3",
|
||||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.17",
|
"@rolldown/binding-openharmony-arm64": "1.0.3",
|
||||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.17",
|
"@rolldown/binding-wasm32-wasi": "1.0.3",
|
||||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17",
|
"@rolldown/binding-win32-arm64-msvc": "1.0.3",
|
||||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17"
|
"@rolldown/binding-win32-x64-msvc": "1.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sade": {
|
"node_modules/sade": {
|
||||||
@@ -3056,9 +3032,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/svelte": {
|
"node_modules/svelte": {
|
||||||
"version": "5.55.5",
|
"version": "5.55.7",
|
||||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.5.tgz",
|
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.7.tgz",
|
||||||
"integrity": "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw==",
|
"integrity": "sha512-ymI5ykLPwIHW839E053FQbI1G+jnRFJEw3Kv5Y4njixVWywQBx+NUFpkkKyk5LIb36Fg9DVXSYpqiGekLD0hyw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -3071,7 +3047,7 @@
|
|||||||
"aria-query": "5.3.1",
|
"aria-query": "5.3.1",
|
||||||
"axobject-query": "^4.1.0",
|
"axobject-query": "^4.1.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"devalue": "^5.6.4",
|
"devalue": "^5.8.1",
|
||||||
"esm-env": "^1.2.1",
|
"esm-env": "^1.2.1",
|
||||||
"esrap": "^2.2.4",
|
"esrap": "^2.2.4",
|
||||||
"is-reference": "^3.0.3",
|
"is-reference": "^3.0.3",
|
||||||
@@ -3225,9 +3201,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.16",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
|
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -3358,17 +3334,17 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.0.10",
|
"version": "8.0.16",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
|
||||||
"integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
|
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lightningcss": "^1.32.0",
|
"lightningcss": "^1.32.0",
|
||||||
"picomatch": "^4.0.4",
|
"picomatch": "^4.0.4",
|
||||||
"postcss": "^8.5.10",
|
"postcss": "^8.5.15",
|
||||||
"rolldown": "1.0.0-rc.17",
|
"rolldown": "1.0.3",
|
||||||
"tinyglobby": "^0.2.16"
|
"tinyglobby": "^0.2.17"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"vite": "bin/vite.js"
|
"vite": "bin/vite.js"
|
||||||
@@ -3384,7 +3360,7 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/node": "^20.19.0 || >=22.12.0",
|
"@types/node": "^20.19.0 || >=22.12.0",
|
||||||
"@vitejs/devtools": "^0.1.0",
|
"@vitejs/devtools": "^0.1.18",
|
||||||
"esbuild": "^0.27.0 || ^0.28.0",
|
"esbuild": "^0.27.0 || ^0.28.0",
|
||||||
"jiti": ">=1.21.0",
|
"jiti": ">=1.21.0",
|
||||||
"less": "^4.0.0",
|
"less": "^4.0.0",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build && node ./scripts/compress-index.js",
|
"build": "vite build && gzip -9 ./build/index.html",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@sveltejs/adapter-auto": "^7.0.1",
|
"@sveltejs/adapter-auto": "^7.0.1",
|
||||||
"@sveltejs/adapter-static": "^3.0.5",
|
"@sveltejs/adapter-static": "^3.0.5",
|
||||||
"@sveltejs/kit": "^2.58.0",
|
"@sveltejs/kit": "^2.60.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"@types/eslint": "^9.6.0",
|
"@types/eslint": "^9.6.0",
|
||||||
@@ -29,12 +29,12 @@
|
|||||||
"globals": "^17.5.0",
|
"globals": "^17.5.0",
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
"prettier-plugin-svelte": "^3.5.1",
|
"prettier-plugin-svelte": "^3.5.1",
|
||||||
"svelte": "^5.55.5",
|
"svelte": "^5.55.7",
|
||||||
"svelte-check": "^4.4.6",
|
"svelte-check": "^4.4.6",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.2",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.59.0",
|
"typescript-eslint": "^8.59.0",
|
||||||
"vite": "^8.0.10",
|
"vite": "^8.0.16",
|
||||||
"vitest": "^4.1.5"
|
"vitest": "^4.1.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import { readFileSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
||||||
import { brotliCompressSync, constants } from 'node:zlib';
|
|
||||||
|
|
||||||
const input = './build/index.html';
|
|
||||||
const output = './build/index.html.br';
|
|
||||||
|
|
||||||
const compressed = brotliCompressSync(readFileSync(input), {
|
|
||||||
params: { [constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY },
|
|
||||||
});
|
|
||||||
writeFileSync(output, compressed);
|
|
||||||
unlinkSync(input);
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { type ReportMetadata } from '$lib/analysis.svelte';
|
import { type ReportMetadata } from '$lib/analysis.svelte';
|
||||||
import type { ManifestEntry } from '$lib/manifest.svelte';
|
import type { ManifestEntry } from '$lib/manifest.svelte';
|
||||||
|
import { gps_mode_label } from '$lib/utils.svelte';
|
||||||
import { AnalysisManager } from '$lib/analysisManager.svelte';
|
import { AnalysisManager } from '$lib/analysisManager.svelte';
|
||||||
import AnalysisTable from './AnalysisTable.svelte';
|
import AnalysisTable from './AnalysisTable.svelte';
|
||||||
import ReAnalyzeButton from './ReAnalyzeButton.svelte';
|
import ReAnalyzeButton from './ReAnalyzeButton.svelte';
|
||||||
@@ -70,6 +71,10 @@
|
|||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
<p>
|
||||||
|
<b>GPS Mode:</b>
|
||||||
|
{gps_mode_label(entry.gps_mode)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{#if metadata && metadata.analyzers}
|
{#if metadata && metadata.analyzers}
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -5,11 +5,14 @@
|
|||||||
test_notification,
|
test_notification,
|
||||||
get_wifi_status,
|
get_wifi_status,
|
||||||
scan_wifi_networks,
|
scan_wifi_networks,
|
||||||
|
GpsMode,
|
||||||
|
enabled_notifications,
|
||||||
type Config,
|
type Config,
|
||||||
type WifiStatus,
|
type WifiStatus,
|
||||||
type WifiNetwork,
|
type WifiNetwork,
|
||||||
} from '../utils.svelte';
|
} from '../utils.svelte';
|
||||||
import Modal from './Modal.svelte';
|
import Modal from './Modal.svelte';
|
||||||
|
import ExpandableInput from './ExpandableInput.svelte';
|
||||||
|
|
||||||
let { shown = $bindable() }: { shown: boolean } = $props();
|
let { shown = $bindable() }: { shown: boolean } = $props();
|
||||||
let config = $state<Config | null>(null);
|
let config = $state<Config | null>(null);
|
||||||
@@ -26,15 +29,12 @@
|
|||||||
let scanning = $state(false);
|
let scanning = $state(false);
|
||||||
let scanResults = $state<WifiNetwork[]>([]);
|
let scanResults = $state<WifiNetwork[]>([]);
|
||||||
let dnsServersInput = $state('');
|
let dnsServersInput = $state('');
|
||||||
let webdavExpanded = $state(false);
|
|
||||||
let webdavUrlInput = $state<HTMLInputElement | null>(null);
|
|
||||||
|
|
||||||
async function load_config() {
|
async function load_config() {
|
||||||
try {
|
try {
|
||||||
loading = true;
|
loading = true;
|
||||||
config = await get_config();
|
config = await get_config();
|
||||||
dnsServersInput = config.dns_servers ? config.dns_servers.join(', ') : '';
|
dnsServersInput = config.dns_servers ? config.dns_servers.join(', ') : '';
|
||||||
webdavExpanded = config.webdav.url.trim() !== '';
|
|
||||||
message = '';
|
message = '';
|
||||||
messageType = null;
|
messageType = null;
|
||||||
poll_wifi_status();
|
poll_wifi_status();
|
||||||
@@ -169,11 +169,11 @@
|
|||||||
bind:value={config.ui_level}
|
bind:value={config.ui_level}
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden 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={0}>Invisible mode</option>
|
||||||
<option value={1}>1 - Subtle mode (colored line)</option>
|
<option value={1}>Subtle mode (colored line)</option>
|
||||||
<option value={2}>2 - Demo mode (orca gif)</option>
|
<option value={2}>Demo mode (orca gif)</option>
|
||||||
<option value={3}>3 - EFF logo</option>
|
<option value={3}>EFF logo</option>
|
||||||
<option value={4}>4 - High visibility (full screen color)</option>
|
<option value={4}>High visibility (full screen color)</option>
|
||||||
</select>
|
</select>
|
||||||
<p class="text-xs text-gray-500 mt-1">
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
Note: Rayhunter draws over the device's native UI, so some flickering is
|
Note: Rayhunter draws over the device's native UI, so some flickering is
|
||||||
@@ -193,9 +193,8 @@
|
|||||||
bind:value={config.key_input_mode}
|
bind:value={config.key_input_mode}
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden 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={0}>Disable button control</option>
|
||||||
<option value={1}>1 - Double-tap power button to start new recording</option
|
<option value={1}>Double-tap power button to start new recording</option>
|
||||||
>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -215,22 +214,32 @@
|
|||||||
|
|
||||||
<div class="border-t border-gray-200 pt-4 mt-6 space-y-3">
|
<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>
|
<h3 class="text-lg font-semibold text-gray-800 mb-4">Notification Settings</h3>
|
||||||
<div>
|
|
||||||
<label for="ntfy_url" class="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
ntfy URL for Sending Notifications (if unset you will not receive
|
|
||||||
notifications)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="ntfy_url"
|
|
||||||
type="url"
|
|
||||||
bind:value={config.ntfy_url}
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-hidden focus:ring-2 focus:ring-rayhunter-blue"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-gray-500 mt-1">
|
|
||||||
Test button below uses the saved configuration URL, not the input above
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div 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>
|
<div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -303,8 +312,23 @@
|
|||||||
Low Battery
|
Low Battery
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
</ExpandableInput>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="border-t border-gray-200 pt-4 mt-6 space-y-3">
|
<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>
|
<h3 class="text-lg font-semibold text-gray-800 mb-4">Storage Management</h3>
|
||||||
@@ -355,53 +379,15 @@
|
|||||||
.qmdl and .ndjson files are uploaded in the background to the WebDAV server.
|
.qmdl and .ndjson files are uploaded in the background to the WebDAV server.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="flex items-center">
|
<ExpandableInput
|
||||||
<input
|
|
||||||
id="webdav_enabled"
|
|
||||||
type="checkbox"
|
|
||||||
checked={webdavExpanded}
|
|
||||||
onchange={(e) => {
|
|
||||||
webdavExpanded = e.currentTarget.checked;
|
|
||||||
if (webdavExpanded) {
|
|
||||||
setTimeout(() => webdavUrlInput?.focus(), 0);
|
|
||||||
} else {
|
|
||||||
if (config) config.webdav.url = '';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded-sm"
|
|
||||||
/>
|
|
||||||
<label for="webdav_enabled" class="ml-2 block text-sm text-gray-700">
|
|
||||||
Enable WebDAV upload
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if webdavExpanded}
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="webdav_url"
|
|
||||||
class="block text-sm font-medium text-gray-700 mb-1"
|
|
||||||
>
|
|
||||||
Server URL
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="webdav_url"
|
|
||||||
type="url"
|
|
||||||
bind:this={webdavUrlInput}
|
|
||||||
bind:value={config.webdav.url}
|
bind:value={config.webdav.url}
|
||||||
onblur={() => {
|
checkboxId="webdav_enabled"
|
||||||
if (config && config.webdav.url.trim() === '') {
|
inputId="webdav_url"
|
||||||
webdavExpanded = false;
|
label="Enable WebDAV upload"
|
||||||
}
|
inputLabel="Server URL"
|
||||||
}}
|
inputPlaceholder="https://dav.example.com/rayhunter/"
|
||||||
placeholder="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."
|
||||||
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">
|
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="webdav_username"
|
for="webdav_username"
|
||||||
@@ -512,7 +498,7 @@
|
|||||||
When enabled, the local files are removed after a successful upload.
|
When enabled, the local files are removed after a successful upload.
|
||||||
Otherwise the manifest is just marked as uploaded.
|
Otherwise the manifest is just marked as uploaded.
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
</ExpandableInput>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if config.device === 'orbic' || config.device === 'moxee' || config.device === 'tmobile' || config.device === 'wingtech'}
|
{#if config.device === 'orbic' || config.device === 'moxee' || config.device === 'tmobile' || config.device === 'wingtech'}
|
||||||
@@ -781,6 +767,74 @@
|
|||||||
</div>
|
</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">
|
<div class="flex gap-2 pt-4">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { type SystemStats } from '$lib/systemStats';
|
import { type SystemStats } from '$lib/systemStats';
|
||||||
|
import { gps_mode_label, GpsMode, type GpsData } from '$lib/utils.svelte';
|
||||||
let {
|
let {
|
||||||
stats,
|
stats,
|
||||||
|
gps_data = null,
|
||||||
|
gps_mode = GpsMode.Disabled,
|
||||||
}: {
|
}: {
|
||||||
stats: SystemStats;
|
stats: SystemStats;
|
||||||
|
gps_data?: GpsData | null;
|
||||||
|
gps_mode?: GpsMode;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const table_cell_classes = 'border border-gray-200 p-1 lg:p-2';
|
|
||||||
|
|
||||||
let battery_level = $derived(stats.battery_status ? stats.battery_status.level : 0);
|
let battery_level = $derived(stats.battery_status ? stats.battery_status.level : 0);
|
||||||
let bar_color = $derived.by(() => {
|
let bar_color = $derived.by(() => {
|
||||||
if (stats.battery_status === undefined) {
|
if (stats.battery_status === undefined) {
|
||||||
@@ -39,28 +42,28 @@
|
|||||||
class="flex-1 drop-shadow-sm 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>
|
<p class="text-xl mb-2">System Information</p>
|
||||||
<table class="table-auto border border-gray-200">
|
<table class="text-sm w-full">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr class="border border-gray-200">
|
<tr class="border-b border-gray-200">
|
||||||
<th class={table_cell_classes}> Rayhunter Version </th>
|
<td class="py-1 pr-4 text-gray-500 font-medium">Rayhunter Version</td>
|
||||||
<td class={table_cell_classes}>{stats.runtime_metadata.rayhunter_version}</td>
|
<td class="py-1">{stats.runtime_metadata.rayhunter_version}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="border border-gray-200">
|
<tr class="border-b border-gray-200">
|
||||||
<th class={table_cell_classes}> Storage </th>
|
<td class="py-1 pr-4 text-gray-500 font-medium">Storage</td>
|
||||||
<td class={table_cell_classes}>
|
<td class="py-1">
|
||||||
{stats.disk_stats.used_percent} used ({stats.disk_stats.used_size} used / {stats
|
{stats.disk_stats.used_percent} used ({stats.disk_stats.used_size} used / {stats
|
||||||
.disk_stats.available_size} available)
|
.disk_stats.available_size} available)
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="border-b border-gray-200">
|
<tr class="border-b border-gray-200">
|
||||||
<th class={table_cell_classes}> Memory (RAM) </th>
|
<td class="py-1 pr-4 text-gray-500 font-medium">Memory (RAM)</td>
|
||||||
<td class={table_cell_classes}>
|
<td class="py-1">
|
||||||
Free: {stats.memory_stats.free}, Used: {stats.memory_stats.used}
|
Free: {stats.memory_stats.free}, Used: {stats.memory_stats.used}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="border-b border-gray-200">
|
<tr class={gps_mode !== GpsMode.Disabled ? 'border-b border-gray-200' : ''}>
|
||||||
<th class={table_cell_classes}> Battery </th>
|
<td class="py-1 pr-4 text-gray-500 font-medium">Battery</td>
|
||||||
<td class={table_cell_classes}>
|
<td class="py-1">
|
||||||
<svg
|
<svg
|
||||||
width="80"
|
width="80"
|
||||||
height="30"
|
height="30"
|
||||||
@@ -70,7 +73,6 @@
|
|||||||
class="battery-icon"
|
class="battery-icon"
|
||||||
>
|
>
|
||||||
<title>{title_text}</title>
|
<title>{title_text}</title>
|
||||||
<!-- Battery body -->
|
|
||||||
<rect
|
<rect
|
||||||
class="fill-none stroke-neutral-800 stroke-2"
|
class="fill-none stroke-neutral-800 stroke-2"
|
||||||
width="70"
|
width="70"
|
||||||
@@ -78,7 +80,6 @@
|
|||||||
rx="3"
|
rx="3"
|
||||||
ry="3"
|
ry="3"
|
||||||
/>
|
/>
|
||||||
<!-- Battery terminal -->
|
|
||||||
<rect
|
<rect
|
||||||
class="fill-neutral-800"
|
class="fill-neutral-800"
|
||||||
x="70"
|
x="70"
|
||||||
@@ -88,7 +89,6 @@
|
|||||||
rx="2"
|
rx="2"
|
||||||
ry="2"
|
ry="2"
|
||||||
/>
|
/>
|
||||||
<!-- Battery charge bar -->
|
|
||||||
<rect
|
<rect
|
||||||
class={bar_color}
|
class={bar_color}
|
||||||
x="2"
|
x="2"
|
||||||
@@ -99,14 +99,12 @@
|
|||||||
style="width: {battery_level * 0.66}px;"
|
style="width: {battery_level * 0.66}px;"
|
||||||
/>
|
/>
|
||||||
{#if stats.battery_status && stats.battery_status.is_plugged_in}
|
{#if stats.battery_status && stats.battery_status.is_plugged_in}
|
||||||
<!-- Lightning bolt icon -->
|
|
||||||
<path
|
<path
|
||||||
class="fill-yellow-300 stroke-neutral-800 stroke-1"
|
class="fill-yellow-300 stroke-neutral-800 stroke-1"
|
||||||
d="M38 3 L28 17 L34 17 L30 27 L40 13 L34 13 Z"
|
d="M38 3 L28 17 L34 17 L30 27 L40 13 L34 13 Z"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !stats.battery_status}
|
{#if !stats.battery_status}
|
||||||
<!-- Question mark icon -->
|
|
||||||
<text
|
<text
|
||||||
class="fill-neutral-500 text-[20px] font-bold [text-anchor:middle] [dominant-baseline:central]"
|
class="fill-neutral-500 text-[20px] font-bold [text-anchor:middle] [dominant-baseline:central]"
|
||||||
x="35"
|
x="35"
|
||||||
@@ -116,6 +114,27 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 { get_report, type AnalysisReport } from './analysis.svelte';
|
||||||
import { AnalysisStatus, type AnalysisManager } from './analysisManager.svelte';
|
import { AnalysisStatus, type AnalysisManager } from './analysisManager.svelte';
|
||||||
|
import { GpsMode } from './utils.svelte';
|
||||||
|
|
||||||
interface JsonManifest {
|
interface JsonManifest {
|
||||||
entries: JsonManifestEntry[];
|
entries: JsonManifestEntry[];
|
||||||
@@ -13,6 +14,7 @@ interface JsonManifestEntry {
|
|||||||
qmdl_size_bytes: number;
|
qmdl_size_bytes: number;
|
||||||
stop_reason: string | null;
|
stop_reason: string | null;
|
||||||
upload_time: string | null;
|
upload_time: string | null;
|
||||||
|
gps_mode: GpsMode | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Manifest {
|
export class Manifest {
|
||||||
@@ -61,6 +63,7 @@ export class ManifestEntry {
|
|||||||
public analysis_report: AnalysisReport | string | undefined = $state(undefined);
|
public analysis_report: AnalysisReport | string | undefined = $state(undefined);
|
||||||
public stop_reason: string | undefined = $state(undefined);
|
public stop_reason: string | undefined = $state(undefined);
|
||||||
public upload_time: Date | undefined = $state(undefined);
|
public upload_time: Date | undefined = $state(undefined);
|
||||||
|
public gps_mode: GpsMode | undefined = $state(undefined);
|
||||||
|
|
||||||
constructor(json: JsonManifestEntry) {
|
constructor(json: JsonManifestEntry) {
|
||||||
this.name = json.name;
|
this.name = json.name;
|
||||||
@@ -75,6 +78,9 @@ export class ManifestEntry {
|
|||||||
if (json.upload_time) {
|
if (json.upload_time) {
|
||||||
this.upload_time = new Date(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 {
|
get_readable_qmdl_size(): string {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface AnalyzerConfig {
|
|||||||
export enum enabled_notifications {
|
export enum enabled_notifications {
|
||||||
Warning = 'Warning',
|
Warning = 'Warning',
|
||||||
LowBattery = 'LowBattery',
|
LowBattery = 'LowBattery',
|
||||||
|
Update = 'Update',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WebdavConfig {
|
export interface WebdavConfig {
|
||||||
@@ -28,13 +29,31 @@ export interface WebdavConfig {
|
|||||||
delete_on_upload: boolean;
|
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 {
|
export interface Config {
|
||||||
device: string;
|
device: string;
|
||||||
ui_level: number;
|
ui_level: number;
|
||||||
colorblind_mode: boolean;
|
colorblind_mode: boolean;
|
||||||
key_input_mode: number;
|
key_input_mode: number;
|
||||||
ntfy_url: string;
|
ntfy_url: string | null;
|
||||||
enabled_notifications: enabled_notifications[];
|
enabled_notifications: enabled_notifications[];
|
||||||
|
auto_check_updates: boolean;
|
||||||
analyzers: AnalyzerConfig;
|
analyzers: AnalyzerConfig;
|
||||||
min_space_to_start_recording_mb: number;
|
min_space_to_start_recording_mb: number;
|
||||||
min_space_to_continue_recording_mb: number;
|
min_space_to_continue_recording_mb: number;
|
||||||
@@ -46,6 +65,9 @@ export interface Config {
|
|||||||
firewall_restrict_outbound: boolean;
|
firewall_restrict_outbound: boolean;
|
||||||
firewall_allowed_ports: number[] | null;
|
firewall_allowed_ports: number[] | null;
|
||||||
webdav: WebdavConfig;
|
webdav: WebdavConfig;
|
||||||
|
gps_mode: GpsMode;
|
||||||
|
gps_fixed_latitude: number | null;
|
||||||
|
gps_fixed_longitude: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WifiStatus {
|
export interface WifiStatus {
|
||||||
@@ -150,6 +172,37 @@ export interface TimeResponse {
|
|||||||
offset_seconds: number;
|
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> {
|
export async function get_daemon_time(): Promise<TimeResponse> {
|
||||||
return JSON.parse(await req('GET', '/api/time'));
|
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,6 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ManifestEntry } from '$lib/manifest.svelte';
|
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 ManifestTable from '$lib/components/ManifestTable.svelte';
|
||||||
import Card from '$lib/components/ManifestCard.svelte';
|
import Card from '$lib/components/ManifestCard.svelte';
|
||||||
import type { SystemStats } from '$lib/systemStats';
|
import type { SystemStats } from '$lib/systemStats';
|
||||||
@@ -12,6 +21,7 @@
|
|||||||
import ActionErrors from '$lib/components/ActionErrors.svelte';
|
import ActionErrors from '$lib/components/ActionErrors.svelte';
|
||||||
import ClockDriftAlert from '$lib/components/ClockDriftAlert.svelte';
|
import ClockDriftAlert from '$lib/components/ClockDriftAlert.svelte';
|
||||||
import LogView from '$lib/components/LogView.svelte';
|
import LogView from '$lib/components/LogView.svelte';
|
||||||
|
import UpdateNotice from '$lib/components/UpdateNotice.svelte';
|
||||||
|
|
||||||
let manager: AnalysisManager = new AnalysisManager();
|
let manager: AnalysisManager = new AnalysisManager();
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
@@ -22,6 +32,9 @@
|
|||||||
let update_error: string | undefined = $state(undefined);
|
let update_error: string | undefined = $state(undefined);
|
||||||
let logview_shown: boolean = $state(false);
|
let logview_shown: boolean = $state(false);
|
||||||
let config_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(() => {
|
$effect(() => {
|
||||||
const interval = setInterval(async () => {
|
const interval = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -40,6 +53,16 @@
|
|||||||
current_entry = new_manifest.current_entry;
|
current_entry = new_manifest.current_entry;
|
||||||
|
|
||||||
system_stats = await get_system_stats();
|
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;
|
update_error = undefined;
|
||||||
loaded = true;
|
loaded = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -240,6 +263,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<ActionErrors />
|
<ActionErrors />
|
||||||
<ClockDriftAlert />
|
<ClockDriftAlert />
|
||||||
|
<UpdateNotice status={update_status} />
|
||||||
{#if loaded}
|
{#if loaded}
|
||||||
<div class="flex flex-col lg:flex-row gap-4">
|
<div class="flex flex-col lg:flex-row gap-4">
|
||||||
{#if current_entry}
|
{#if current_entry}
|
||||||
@@ -281,7 +305,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<SystemStatsTable stats={system_stats!} />
|
<SystemStatsTable stats={system_stats!} {gps_data} {gps_mode} />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<div class="flex flex-row gap-2">
|
<div class="flex flex-row gap-2">
|
||||||
|
|||||||
Vendored
+4
@@ -28,6 +28,10 @@ key_input_mode = 0
|
|||||||
# What notification types to enable. Does nothing if the above ntfy_url is not set.
|
# What notification types to enable. Does nothing if the above ntfy_url is not set.
|
||||||
enabled_notifications = ["Warning", "LowBattery"]
|
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
|
# Disk Space Management
|
||||||
# Minimum free space (MB) required to start recording
|
# Minimum free space (MB) required to start recording
|
||||||
min_space_to_start_recording_mb = 1
|
min_space_to_start_recording_mb = 1
|
||||||
|
|||||||
+16
-6
@@ -15,12 +15,28 @@ Through web UI you can set:
|
|||||||
- *Disable button control*: built-in power button of the device is not used by Rayhunter.
|
- *Disable button control*: built-in power button of the device is not used by Rayhunter.
|
||||||
- *Double-tap power button to start new recording*: double clicking on a built-in power button of the device stops and immediately restarts the recording. This could be useful if Rayhunter's heuristics is triggered and you get the red line, and you want to "reset" the past warnings. Normally you can do that through web UI, but sometimes it is easier to double tap on power button.
|
- *Double-tap power button to start new recording*: double clicking on a built-in power button of the device stops and immediately restarts the recording. This could be useful if Rayhunter's heuristics is triggered and you get the red line, and you want to "reset" the past warnings. Normally you can do that through web UI, but sometimes it is easier to double tap on power button.
|
||||||
- **Colorblind Mode** enables color blind mode (blue line is shown instead of green line, red line remains red). Please note that this does not cover all types of color blindness, but switching green to blue should be about enough to differentiate the color change for most types of color blindness.
|
- **Colorblind Mode** enables color blind mode (blue line is shown instead of green line, red line remains red). Please note that this does not cover all types of color blindness, but switching green to blue should be about enough to differentiate the color change for most types of color blindness.
|
||||||
|
- **Automatically check for software updates** enables periodic checks against the Rayhunter GitHub releases page. When a newer release is found, the web UI shows a notice and, if ntfy update notifications are enabled, a notification is sent.
|
||||||
- **ntfy URL**, which allows setting a [ntfy](https://ntfy.sh/) URL to which notifications of new detections will be sent. The topic should be unique to your device, e.g., `https://ntfy.sh/rayhunter_notifications_ba9di7ie` or `https://myserver.example.com/rayhunter_notifications_ba9di7ie`. The ntfy Android and iOS apps can then be used to receive notifications. More information can be found in the [ntfy docs](https://docs.ntfy.sh/).
|
- **ntfy URL**, which allows setting a [ntfy](https://ntfy.sh/) URL to which notifications of new detections will be sent. The topic should be unique to your device, e.g., `https://ntfy.sh/rayhunter_notifications_ba9di7ie` or `https://myserver.example.com/rayhunter_notifications_ba9di7ie`. The ntfy Android and iOS apps can then be used to receive notifications. More information can be found in the [ntfy docs](https://docs.ntfy.sh/).
|
||||||
- **Enabled Notification Types** allows enabling or disabling the following types of notifications:
|
- **Enabled Notification Types** allows enabling or disabling the following types of notifications:
|
||||||
- *Warnings*, which will alert when a heuristic is triggered. Alerts will be sent at most once every five minutes.
|
- *Warnings*, which will alert when a heuristic is triggered. Alerts will be sent at most once every five minutes.
|
||||||
- *Low Battery*, which will alert when the device's battery is low. Notifications may not be supported for all devices—you can check if your device is supported by looking at whether the battery level indicator is functioning on the System Information section of the Rayhunter UI.
|
- *Low Battery*, which will alert when the device's battery is low. Notifications may not be supported for all devices—you can check if your device is supported by looking at whether the battery level indicator is functioning on the System Information section of the Rayhunter UI.
|
||||||
|
- *Software Updates*, which will alert when a new Rayhunter release is available. Only triggers when *Automatically check for software updates* is enabled.
|
||||||
- With **Analyzer Heuristic Settings** you can switch on or off built-in [Rayhunter heuristics](heuristics.md). Some heuristics are experimental or can trigger a lot of false positive warnings in some networks (our tests have shown that some heuristics have different behavior in US or European networks). In that case you can decide whether you would like to have the heuristics that trigger a lot of false positives on or off. Please note that we are constantly improving and adding new heuristics, so a new release may reduce false positives in existing heuristics as well.
|
- With **Analyzer Heuristic Settings** you can switch on or off built-in [Rayhunter heuristics](heuristics.md). Some heuristics are experimental or can trigger a lot of false positive warnings in some networks (our tests have shown that some heuristics have different behavior in US or European networks). In that case you can decide whether you would like to have the heuristics that trigger a lot of false positives on or off. Please note that we are constantly improving and adding new heuristics, so a new release may reduce false positives in existing heuristics as well.
|
||||||
|
|
||||||
|
## GPS
|
||||||
|
|
||||||
|
The **GPS Settings** allows you to attach GPS-based location history to every recording. Data is stored as a separate JSON file next to QMDL, and also inlined into the PCAP file as packet comment.
|
||||||
|
|
||||||
|
The modes are:
|
||||||
|
|
||||||
|
- *Disabled*, the default option, disables this feature entirely.
|
||||||
|
|
||||||
|
- *Fixed*, for hardcoding latitude (-90 to 90) and longitude (-180 to 180) for devices that don't move very often or at all. Every packet in the recording will have that location.
|
||||||
|
|
||||||
|
- *API Endpoint*, enables the `POST /api/gps` endpoint so that third-party tools (i.e. your own scripts) can update location info continuously. Please refer to the [API documentation](api-docs.md) for more info.
|
||||||
|
|
||||||
|
The GPS data is stored as a separate JSON file next to QMDL captures, and contains its own timestamps. These timestamps are meant to be compared during analysis with the packet timestamp so we know the time difference between the packet capture from the GPS capture, if there is any, since GPS data and packet data may come from two entirely separate devices.
|
||||||
|
|
||||||
## WiFi Client Mode
|
## WiFi Client Mode
|
||||||
|
|
||||||
On the **Orbic**, **Moxee**, **UZ801**, **TMOHS1**, and **Wingtech**, Rayhunter can connect the device to an existing WiFi network while keeping the hotspot running. This gives the device internet access for [notifications](https://docs.ntfy.sh/) and lets you reach the web UI from any device on that network.
|
On the **Orbic**, **Moxee**, **UZ801**, **TMOHS1**, and **Wingtech**, Rayhunter can connect the device to an existing WiFi network while keeping the hotspot running. This gives the device internet access for [notifications](https://docs.ntfy.sh/) and lets you reach the web UI from any device on that network.
|
||||||
@@ -45,12 +61,6 @@ On the first detection of a crash, a diagnostic snapshot is saved to `/data/rayh
|
|||||||
|
|
||||||
If recovery fails after 5 attempts, the status will change to **failed**. A reboot of the device will reset WiFi.
|
If recovery fails after 5 attempts, the status will change to **failed**. A reboot of the device will reset WiFi.
|
||||||
|
|
||||||
You can also configure WiFi during installation:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
./installer orbic --admin-password 'mypassword' --wifi-ssid 'MyNetwork' --wifi-password 'networkpass'
|
|
||||||
```
|
|
||||||
|
|
||||||
## WebDAV Upload
|
## WebDAV Upload
|
||||||
|
|
||||||
Rayhunter can automatically upload finished recordings to a WebDAV server. When a `[webdav]` section is present in `config.toml`, a background worker periodically scans the recording store and uploads any closed entry that is older than `min_age_secs`. Each eligible entry uploads two files: the raw `.qmdl` capture and its `.ndjson` analysis output. After a successful upload the entry is either marked as uploaded in the manifest (and skipped on subsequent polls), or deleted locally if `delete_on_upload = true`. With no `[webdav]` section, no upload worker runs.
|
Rayhunter can automatically upload finished recordings to a WebDAV server. When a `[webdav]` section is present in `config.toml`, a background worker periodically scans the recording store and uploads any closed entry that is older than `min_age_secs`. Each eligible entry uploads two files: the raw `.qmdl` capture and its `.ndjson` analysis output. After a successful upload the entry is either marked as uploaded in the manifest (and skipped on subsequent polls), or deleted locally if `delete_on_upload = true`. With no `[webdav]` section, no upload worker runs.
|
||||||
|
|||||||
@@ -41,27 +41,21 @@ Make sure you've got one of Rayhunter's [supported devices](./supported-devices.
|
|||||||
```bash
|
```bash
|
||||||
# For Orbic:
|
# For Orbic:
|
||||||
./installer orbic --admin-password 'mypassword'
|
./installer orbic --admin-password 'mypassword'
|
||||||
|
|
||||||
# Note: the arguments --admin-username 'myusername' and --admin-ip 'mydeviceip'
|
# Note: the arguments --admin-username 'myusername' and --admin-ip 'mydeviceip'
|
||||||
# may be required if different from the default.
|
# may be required if different from the default.
|
||||||
|
|
||||||
# Or install over USB if you want ADB and a root shell (not recommended for most users)
|
|
||||||
./installer orbic-usb
|
|
||||||
|
|
||||||
# For TP-Link:
|
# For TP-Link:
|
||||||
./installer tplink
|
./installer tplink
|
||||||
```
|
```
|
||||||
|
|
||||||
* On Verizon Orbic, the password is the one used to login to the device's admin menu, and the default is the WiFi password.
|
* On Verizon Orbic, the default password is the WiFi password. You can reset the password by pressing the button under the back case until the unit restarts.
|
||||||
* ***Note:*** If you have changed the device username, password, or IP address from their default values, these must be provided as arguments to the installer command above.
|
* ***Note:*** If you have changed the device username, password, or IP address from their default values, these must be provided as arguments to the installer command above.
|
||||||
* On Kajeet/Smartspot devices, the default password is `$m@rt$p0tc0nf!g`
|
* TP-Link does not require an `--admin-password` parameter.
|
||||||
* On Moxee-brand devices, check under the battery for the password.
|
|
||||||
* You can reset the password by pressing the button under the back case until the unit restarts.
|
|
||||||
|
|
||||||
TP-Link does not require an `--admin-password` parameter.
|
|
||||||
|
|
||||||
For other devices, check `./installer --help` or the
|
For other devices, check `./installer --help` or the
|
||||||
respective page in the sidebar under "Supported
|
respective page in the sidebar under "Supported
|
||||||
Devices."
|
Devices" for the installation command.
|
||||||
|
|
||||||
7. The installer will eventually tell you it's done, and the device will reboot.
|
7. The installer will eventually tell you it's done, and the device will reboot.
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,25 @@ Then you can build everything with:
|
|||||||
./scripts/install-dev.sh orbic # replace 'orbic' with your device type
|
./scripts/install-dev.sh orbic # replace 'orbic' with your device type
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Running the daemon on your PC
|
||||||
|
|
||||||
|
If you don't have a target device handy, you can run `rayhunter-daemon` on your
|
||||||
|
PC with `debug_mode = true`. This skips DIAG, the device display, key input,
|
||||||
|
the battery worker, and the WiFi client, so recording-related endpoints will
|
||||||
|
not work, but the frontend and read-only APIs do.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mkdir -p ./qmdl && printf 'entries = []\n' > ./qmdl/manifest.toml
|
||||||
|
cat > config.toml <<'EOF'
|
||||||
|
qmdl_store_path = "./qmdl"
|
||||||
|
port = 8080
|
||||||
|
debug_mode = true
|
||||||
|
EOF
|
||||||
|
cargo run -p rayhunter-daemon -- ./config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://127.0.0.1:8080`.
|
||||||
|
|
||||||
## Hot-reloading the frontend
|
## Hot-reloading the frontend
|
||||||
|
|
||||||
If you are working on the frontend, you normally have to repeat all of the above steps everytime to see a change.
|
If you are working on the frontend, you normally have to repeat all of the above steps everytime to see a change.
|
||||||
|
|||||||
+16
-7
@@ -27,21 +27,30 @@ According to [FCC ID 2APQU-K779HSDL](https://fcc.report/FCC-ID/2APQU-K779HSDL),
|
|||||||
| 66 | 1700/2100 MHz (E-AWS) |
|
| 66 | 1700/2100 MHz (E-AWS) |
|
||||||
| 71 | 600 MHz |
|
| 71 | 600 MHz |
|
||||||
|
|
||||||
## Installation
|
## Installing
|
||||||
|
|
||||||
Connect to the hotspot's network using WiFi or USB tethering and run:
|
To get started, follow the [release installation guide](./installing-from-release.md). Then run the installer with the following command:
|
||||||
|
|
||||||
```sh
|
```bash
|
||||||
./installer moxee --admin-password 'mypassword'
|
./installer moxee --admin-password 'mypassword'
|
||||||
|
|
||||||
|
# Note: the arguments --admin-username 'myusername' and --admin-ip 'mydeviceip'
|
||||||
|
# may be required if different from the default.
|
||||||
```
|
```
|
||||||
|
|
||||||
The password (in place of `mypassword`) is under the battery.
|
* The password is the one used to log in to the device's admin menu. You can reset the password by pressing the button under the back case until the unit restarts.
|
||||||
|
* ***Note:*** If you have changed the device username, password, or IP address from their default values, these must be provided as arguments to the installer command above.
|
||||||
|
* On Moxee-brand devices, check under the battery for the password.
|
||||||
|
* `./installer moxee` is almost the same as `./installer orbic`, it just comes with slightly better defaults that will give you more space for recordings.
|
||||||
|
|
||||||
`./installer moxee` is almost the same as `./installer orbic`, it just comes
|
The Rayhunter UI will be available at <http://192.168.1.1:8080>.
|
||||||
with slightly better defaults that will give you more space for recordings.
|
|
||||||
|
|
||||||
|
<a name=shell></a>
|
||||||
## Obtaining a shell
|
## Obtaining a shell
|
||||||
|
|
||||||
```sh
|
After running the installer, there will not be a root shell and ADB will not be
|
||||||
|
enabled. Instead, you can use:
|
||||||
|
|
||||||
|
```bash
|
||||||
./installer util orbic-shell
|
./installer util orbic-shell
|
||||||
```
|
```
|
||||||
|
|||||||
+31
-1
@@ -42,11 +42,41 @@ The orbic's installation routine underwent many different changes:
|
|||||||
It's possible that many tutorials out there still refer to some of the old
|
It's possible that many tutorials out there still refer to some of the old
|
||||||
installation routines.
|
installation routines.
|
||||||
|
|
||||||
|
## Installing
|
||||||
|
|
||||||
|
To get started, follow the [release installation guide](./installing-from-release.md). Then run the installer with the following command:
|
||||||
|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# For Orbic:
|
||||||
|
./installer orbic --admin-password 'mypassword'
|
||||||
|
|
||||||
|
# For Kajeet/Smartspot:
|
||||||
|
./installer orbic --admin-password '$m@rt$p0tc0nf!g'
|
||||||
|
|
||||||
|
# Note: the arguments --admin-username 'myusername' and --admin-ip 'mydeviceip'
|
||||||
|
# may be required if different from the default.
|
||||||
|
|
||||||
|
# Or install over USB if you want ADB and a root shell (not recommended for most users)
|
||||||
|
./installer orbic-usb
|
||||||
|
```
|
||||||
|
|
||||||
|
* The password is the one used to log in to the device's admin menu. You can reset the password by pressing the button under the back case until the unit restarts.
|
||||||
|
* ***Note:*** If you have changed the device username, password, or IP address from their default values, these must be provided as arguments to the installer command above.
|
||||||
|
* On Verizon Orbic, the default password is the WiFi password.
|
||||||
|
* On Kajeet/Smartspot devices, the default password is `$m@rt$p0tc0nf!g`
|
||||||
|
|
||||||
|
The Rayhunter UI will be available at <http://192.168.1.1:8080>.
|
||||||
|
|
||||||
<a name=shell></a>
|
<a name=shell></a>
|
||||||
## Obtaining a shell
|
## Obtaining a shell
|
||||||
|
|
||||||
After running the installer, there will not be a root shell and ADB will not be
|
After running the installer, there will not be a root shell and ADB will not be
|
||||||
enabled. Instead you can use `./installer util orbic-shell`.
|
enabled. Instead, you can use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./installer util orbic-shell
|
||||||
|
```
|
||||||
|
|
||||||
If you are using an installer prior to 0.7.0 or `orbic-usb` explicitly, you can
|
If you are using an installer prior to 0.7.0 or `orbic-usb` explicitly, you can
|
||||||
obtain a root shell by running `adb shell` or `./installer util shell`. Then,
|
obtain a root shell by running `adb shell` or `./installer util shell`. Then,
|
||||||
|
|||||||
+1
-1
@@ -55,7 +55,7 @@ You can copy the daemon and config files to the device using `netcat` or `adb pu
|
|||||||
|
|
||||||
The `device` setting in `config.toml` must match one of the lowercase variant names from the `Device` enum (e.g. `"orbic"`, `"tplink"`). This controls which display driver is used.
|
The `device` setting in `config.toml` must match one of the lowercase variant names from the `Device` enum (e.g. `"orbic"`, `"tplink"`). This controls which display driver is used.
|
||||||
|
|
||||||
Setting `debug_mode = true` in `config.toml` runs the daemon without `/dev/diag`, so you can test the display and web UI without the hardware.
|
To bring the daemon up without `/dev/diag` (for instance, to test the display and web UI before the hardware path works), see [Running the daemon on your PC](./installing-from-source.md#running-the-daemon-on-your-pc).
|
||||||
|
|
||||||
### Autostart
|
### Autostart
|
||||||
|
|
||||||
|
|||||||
+9
-2
@@ -38,10 +38,17 @@ You can get your TP-Link M7350 from:
|
|||||||
* [Ebay](https://www.ebay.com/sch/i.html?_nkw=tp-link+m7350&_sacat=0&_from=R40&_trksid=p4432023.m570.l1313).
|
* [Ebay](https://www.ebay.com/sch/i.html?_nkw=tp-link+m7350&_sacat=0&_from=R40&_trksid=p4432023.m570.l1313).
|
||||||
* Can also be found sold as the 'Vodafone Pocket Wifi 5' in Australia
|
* Can also be found sold as the 'Vodafone Pocket Wifi 5' in Australia
|
||||||
|
|
||||||
## Installation & Usage
|
## Installing
|
||||||
|
|
||||||
Follow the [release installation guide](./installing-from-release.md). Substitute `./installer orbic` for `./installer tplink` in other documentation. The Rayhunter UI will be available at <http://192.168.0.1:8080>.
|
To get started, follow the [release installation guide](./installing-from-release.md). Then run the installer with the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./installer tplink
|
||||||
|
```
|
||||||
|
|
||||||
|
The Rayhunter UI will be available at <http://192.168.0.1:8080>.
|
||||||
|
|
||||||
|
<a name=shell></a>
|
||||||
## Obtaining a shell
|
## Obtaining a shell
|
||||||
|
|
||||||
You can obtain a root shell with the following command:
|
You can obtain a root shell with the following command:
|
||||||
|
|||||||
Generated
+135
-132
@@ -16,7 +16,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@sveltejs/adapter-static": "^3.0.6",
|
"@sveltejs/adapter-static": "^3.0.6",
|
||||||
"@sveltejs/kit": "^2.57.1",
|
"@sveltejs/kit": "^2.60.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
"eslint": "^10.2.1",
|
"eslint": "^10.2.1",
|
||||||
@@ -25,17 +25,17 @@
|
|||||||
"globals": "^17.5.0",
|
"globals": "^17.5.0",
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
"prettier-plugin-svelte": "^3.5.1",
|
"prettier-plugin-svelte": "^3.5.1",
|
||||||
"svelte": "^5.55.4",
|
"svelte": "^5.55.7",
|
||||||
"svelte-check": "^4.4.6",
|
"svelte-check": "^4.4.6",
|
||||||
"typescript": "~6.0.3",
|
"typescript": "~6.0.3",
|
||||||
"typescript-eslint": "^8.58.2",
|
"typescript-eslint": "^8.58.2",
|
||||||
"vite": "^8.0.9"
|
"vite": "^8.0.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.9.2",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||||
"integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
|
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -44,9 +44,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/runtime": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.9.2",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||||
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
|
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -289,13 +289,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||||
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
|
"integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tybys/wasm-util": "^0.10.1"
|
"@tybys/wasm-util": "^0.10.2"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -307,9 +307,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-project/types": {
|
"node_modules/@oxc-project/types": {
|
||||||
"version": "0.126.0",
|
"version": "0.133.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.126.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
|
||||||
"integrity": "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==",
|
"integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
@@ -323,9 +323,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.0.0-rc.16",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.16.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
|
||||||
"integrity": "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==",
|
"integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -339,9 +339,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||||
"version": "1.0.0-rc.16",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.16.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz",
|
||||||
"integrity": "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ==",
|
"integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -355,9 +355,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-darwin-x64": {
|
"node_modules/@rolldown/binding-darwin-x64": {
|
||||||
"version": "1.0.0-rc.16",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.16.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz",
|
||||||
"integrity": "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ==",
|
"integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -371,9 +371,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||||
"version": "1.0.0-rc.16",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.16.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz",
|
||||||
"integrity": "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g==",
|
"integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -387,9 +387,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||||
"version": "1.0.0-rc.16",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.16.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz",
|
||||||
"integrity": "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg==",
|
"integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -403,12 +403,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||||
"version": "1.0.0-rc.16",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.16.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz",
|
||||||
"integrity": "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg==",
|
"integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -419,12 +422,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||||
"version": "1.0.0-rc.16",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.16.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz",
|
||||||
"integrity": "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==",
|
"integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -435,12 +441,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||||
"version": "1.0.0-rc.16",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.16.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz",
|
||||||
"integrity": "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==",
|
"integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -451,12 +460,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||||
"version": "1.0.0-rc.16",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.16.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz",
|
||||||
"integrity": "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==",
|
"integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -467,12 +479,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||||
"version": "1.0.0-rc.16",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.16.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz",
|
||||||
"integrity": "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg==",
|
"integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -483,12 +498,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||||
"version": "1.0.0-rc.16",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.16.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz",
|
||||||
"integrity": "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==",
|
"integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -499,9 +517,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||||
"version": "1.0.0-rc.16",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.16.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz",
|
||||||
"integrity": "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==",
|
"integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -515,17 +533,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||||
"version": "1.0.0-rc.16",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.16.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz",
|
||||||
"integrity": "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ==",
|
"integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"wasm32"
|
"wasm32"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emnapi/core": "1.9.2",
|
"@emnapi/core": "1.10.0",
|
||||||
"@emnapi/runtime": "1.9.2",
|
"@emnapi/runtime": "1.10.0",
|
||||||
"@napi-rs/wasm-runtime": "^1.1.4"
|
"@napi-rs/wasm-runtime": "^1.1.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -533,9 +551,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||||
"version": "1.0.0-rc.16",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.16.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
|
||||||
"integrity": "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q==",
|
"integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -549,9 +567,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||||
"version": "1.0.0-rc.16",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.16.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz",
|
||||||
"integrity": "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g==",
|
"integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -565,9 +583,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-rc.16",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.16.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||||
"integrity": "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA==",
|
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
"node_modules/@standard-schema/spec": {
|
||||||
@@ -598,9 +616,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@sveltejs/kit": {
|
"node_modules/@sveltejs/kit": {
|
||||||
"version": "2.57.1",
|
"version": "2.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.60.1.tgz",
|
||||||
"integrity": "sha512-VRdSbB96cI1EnRh09CqmnQqP/YJvET5buj8S6k7CxaJqBJD4bw4fRKDjcarAj/eX9k2eHifQfDH8NtOh+ZxxPw==",
|
"integrity": "sha512-mQjlkNo+rJvpln7V2IGY2j99BqhcFbS4UN0AQNKNYfhBAFZTuCDAdW3a1sgf330mvtNvsBXn3HpAhcmvdJTcIQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -609,7 +627,7 @@
|
|||||||
"@types/cookie": "^0.6.0",
|
"@types/cookie": "^0.6.0",
|
||||||
"acorn": "^8.14.1",
|
"acorn": "^8.14.1",
|
||||||
"cookie": "^0.6.0",
|
"cookie": "^0.6.0",
|
||||||
"devalue": "^5.6.4",
|
"devalue": "^5.8.1",
|
||||||
"esm-env": "^1.2.2",
|
"esm-env": "^1.2.2",
|
||||||
"kleur": "^4.1.5",
|
"kleur": "^4.1.5",
|
||||||
"magic-string": "^0.30.5",
|
"magic-string": "^0.30.5",
|
||||||
@@ -1073,9 +1091,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 OR MIT",
|
"license": "Apache-2.0 OR MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1093,9 +1108,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 OR MIT",
|
"license": "Apache-2.0 OR MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1113,9 +1125,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 OR MIT",
|
"license": "Apache-2.0 OR MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1133,9 +1142,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 OR MIT",
|
"license": "Apache-2.0 OR MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1153,9 +1159,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 OR MIT",
|
"license": "Apache-2.0 OR MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1226,9 +1229,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.1",
|
"version": "0.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||||
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
|
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1705,9 +1708,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/devalue": {
|
"node_modules/devalue": {
|
||||||
"version": "5.6.4",
|
"version": "5.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz",
|
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
|
||||||
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==",
|
"integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -2626,9 +2629,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.12",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -2750,9 +2753,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.10",
|
"version": "8.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||||
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
|
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -2769,7 +2772,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.12",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
},
|
},
|
||||||
@@ -2947,13 +2950,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rolldown": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.0.0-rc.16",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.16.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
|
||||||
"integrity": "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g==",
|
"integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oxc-project/types": "=0.126.0",
|
"@oxc-project/types": "=0.133.0",
|
||||||
"@rolldown/pluginutils": "1.0.0-rc.16"
|
"@rolldown/pluginutils": "^1.0.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"rolldown": "bin/cli.mjs"
|
"rolldown": "bin/cli.mjs"
|
||||||
@@ -2962,21 +2965,21 @@
|
|||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rolldown/binding-android-arm64": "1.0.0-rc.16",
|
"@rolldown/binding-android-arm64": "1.0.3",
|
||||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.16",
|
"@rolldown/binding-darwin-arm64": "1.0.3",
|
||||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.16",
|
"@rolldown/binding-darwin-x64": "1.0.3",
|
||||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.16",
|
"@rolldown/binding-freebsd-x64": "1.0.3",
|
||||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16",
|
"@rolldown/binding-linux-arm-gnueabihf": "1.0.3",
|
||||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16",
|
"@rolldown/binding-linux-arm64-gnu": "1.0.3",
|
||||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16",
|
"@rolldown/binding-linux-arm64-musl": "1.0.3",
|
||||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16",
|
"@rolldown/binding-linux-ppc64-gnu": "1.0.3",
|
||||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16",
|
"@rolldown/binding-linux-s390x-gnu": "1.0.3",
|
||||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16",
|
"@rolldown/binding-linux-x64-gnu": "1.0.3",
|
||||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.16",
|
"@rolldown/binding-linux-x64-musl": "1.0.3",
|
||||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.16",
|
"@rolldown/binding-openharmony-arm64": "1.0.3",
|
||||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.16",
|
"@rolldown/binding-wasm32-wasi": "1.0.3",
|
||||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16",
|
"@rolldown/binding-win32-arm64-msvc": "1.0.3",
|
||||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16"
|
"@rolldown/binding-win32-x64-msvc": "1.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sade": {
|
"node_modules/sade": {
|
||||||
@@ -3060,9 +3063,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/svelte": {
|
"node_modules/svelte": {
|
||||||
"version": "5.55.4",
|
"version": "5.55.7",
|
||||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.4.tgz",
|
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.7.tgz",
|
||||||
"integrity": "sha512-q8DFohk6vUswSng95IZb9nzWJnbINZsK7OiM1snAa3qCjJBL0ZQpvMyAaVXjUukdM75J/m8UE8xwqat8Ors/zQ==",
|
"integrity": "sha512-ymI5ykLPwIHW839E053FQbI1G+jnRFJEw3Kv5Y4njixVWywQBx+NUFpkkKyk5LIb36Fg9DVXSYpqiGekLD0hyw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -3075,7 +3078,7 @@
|
|||||||
"aria-query": "5.3.1",
|
"aria-query": "5.3.1",
|
||||||
"axobject-query": "^4.1.0",
|
"axobject-query": "^4.1.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"devalue": "^5.6.4",
|
"devalue": "^5.8.1",
|
||||||
"esm-env": "^1.2.1",
|
"esm-env": "^1.2.1",
|
||||||
"esrap": "^2.2.4",
|
"esrap": "^2.2.4",
|
||||||
"is-reference": "^3.0.3",
|
"is-reference": "^3.0.3",
|
||||||
@@ -3161,9 +3164,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.16",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
|
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -3275,16 +3278,16 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.0.9",
|
"version": "8.0.16",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
|
||||||
"integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==",
|
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lightningcss": "^1.32.0",
|
"lightningcss": "^1.32.0",
|
||||||
"picomatch": "^4.0.4",
|
"picomatch": "^4.0.4",
|
||||||
"postcss": "^8.5.10",
|
"postcss": "^8.5.15",
|
||||||
"rolldown": "1.0.0-rc.16",
|
"rolldown": "1.0.3",
|
||||||
"tinyglobby": "^0.2.16"
|
"tinyglobby": "^0.2.17"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"vite": "bin/vite.js"
|
"vite": "bin/vite.js"
|
||||||
@@ -3300,7 +3303,7 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/node": "^20.19.0 || >=22.12.0",
|
"@types/node": "^20.19.0 || >=22.12.0",
|
||||||
"@vitejs/devtools": "^0.1.0",
|
"@vitejs/devtools": "^0.1.18",
|
||||||
"esbuild": "^0.27.0 || ^0.28.0",
|
"esbuild": "^0.27.0 || ^0.28.0",
|
||||||
"jiti": ">=1.21.0",
|
"jiti": ">=1.21.0",
|
||||||
"less": "^4.0.0",
|
"less": "^4.0.0",
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@sveltejs/adapter-static": "^3.0.6",
|
"@sveltejs/adapter-static": "^3.0.6",
|
||||||
"@sveltejs/kit": "^2.57.1",
|
"@sveltejs/kit": "^2.60.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
"eslint": "^10.2.1",
|
"eslint": "^10.2.1",
|
||||||
@@ -33,10 +33,10 @@
|
|||||||
"globals": "^17.5.0",
|
"globals": "^17.5.0",
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
"prettier-plugin-svelte": "^3.5.1",
|
"prettier-plugin-svelte": "^3.5.1",
|
||||||
"svelte": "^5.55.4",
|
"svelte": "^5.55.7",
|
||||||
"svelte-check": "^4.4.6",
|
"svelte-check": "^4.4.6",
|
||||||
"typescript": "~6.0.3",
|
"typescript": "~6.0.3",
|
||||||
"typescript-eslint": "^8.58.2",
|
"typescript-eslint": "^8.58.2",
|
||||||
"vite": "^8.0.9"
|
"vite": "^8.0.16"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "installer-gui"
|
name = "installer-gui"
|
||||||
version = "0.11.0"
|
version = "0.11.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
@@ -16,6 +16,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
|||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
clap = { version = "4.5.37" }
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
@@ -23,3 +24,4 @@ serde_json = "1"
|
|||||||
anyhow = "1.0.100"
|
anyhow = "1.0.100"
|
||||||
shlex = "1"
|
shlex = "1"
|
||||||
installer = { path = "../../installer" }
|
installer = { path = "../../installer" }
|
||||||
|
log = "0.4.20"
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
//! Combines the values we care about from the clap CLI and the modifiers module into something
|
||||||
|
//! serializable by serde.
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
|
use log::error;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::modifiers;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct Command<'a> {
|
||||||
|
subcommands: Vec<Subcommand<'a>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Command<'_> {
|
||||||
|
pub fn new(command: &clap::Command) -> Command<'_> {
|
||||||
|
let subcommand_map: HashMap<&str, &clap::Command> = command
|
||||||
|
.get_subcommands()
|
||||||
|
.map(|s| (s.get_name(), s))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Command {
|
||||||
|
// this resulting vector contains the subcommands that are found in both
|
||||||
|
// command.get_subcommands() and modifiers::subcommand_modifiers() in the order defined
|
||||||
|
// by subcommand_modifiers()
|
||||||
|
subcommands: modifiers::subcommand_modifiers()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|modifier| match subcommand_map.get(modifier.command) {
|
||||||
|
Some(clap_command) => Some(Subcommand::new(clap_command, modifier)),
|
||||||
|
None => {
|
||||||
|
error!(
|
||||||
|
"failed to find clap command for subcommand {}",
|
||||||
|
modifier.command
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct Argument<'a> {
|
||||||
|
advanced: bool,
|
||||||
|
flag: String,
|
||||||
|
label: &'a str,
|
||||||
|
takes_values: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct Subcommand<'a> {
|
||||||
|
arguments: Vec<Argument<'a>>,
|
||||||
|
command: &'a str,
|
||||||
|
label: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Argument<'_> {
|
||||||
|
fn try_new<'a>(
|
||||||
|
argument: &'a clap::Arg,
|
||||||
|
modifier: &modifiers::ArgumentModifier<'static>,
|
||||||
|
) -> anyhow::Result<Argument<'a>> {
|
||||||
|
let partial_flag = argument.get_long().with_context(|| {
|
||||||
|
format!(
|
||||||
|
"Missing long form command line flag for {}",
|
||||||
|
argument.get_id().as_str(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(Argument {
|
||||||
|
advanced: modifier.advanced,
|
||||||
|
flag: format!("--{}", partial_flag),
|
||||||
|
label: modifier.gui_label,
|
||||||
|
takes_values: argument.get_action().takes_values(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Subcommand<'_> {
|
||||||
|
fn new<'a>(
|
||||||
|
command: &'a clap::Command,
|
||||||
|
modifier: &modifiers::SubcommandModifier<'static>,
|
||||||
|
) -> Subcommand<'a> {
|
||||||
|
let argument_map: HashMap<&str, &clap::Arg> = command
|
||||||
|
.get_arguments()
|
||||||
|
.map(|a| (a.get_id().as_str(), a))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Subcommand {
|
||||||
|
// this resulting vector contains the arguments that are found in both
|
||||||
|
// command.get_arguments() and modifier.arg_modifiers in the order defined by by
|
||||||
|
// arg_modifiers
|
||||||
|
arguments: modifier
|
||||||
|
.arg_modifiers
|
||||||
|
.iter()
|
||||||
|
.filter_map(|arg_modifier| {
|
||||||
|
let Some(arg) = argument_map.get(arg_modifier.clap_id) else {
|
||||||
|
error!(
|
||||||
|
"failed to find clap argument with id {}",
|
||||||
|
arg_modifier.clap_id
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
match Argument::try_new(arg, arg_modifier) {
|
||||||
|
Ok(modified_arg) => Some(modified_arg),
|
||||||
|
Err(err) => {
|
||||||
|
error!("failed to create modified argument: {:?}", err);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
command: modifier.command,
|
||||||
|
label: modifier.gui_label,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
|
use clap::CommandFactory;
|
||||||
use tauri::Emitter;
|
use tauri::Emitter;
|
||||||
|
|
||||||
|
mod introspect;
|
||||||
|
mod modifiers;
|
||||||
|
|
||||||
|
static INSTALLER_COMMAND: LazyLock<clap::Command> = LazyLock::new(installer::Args::command);
|
||||||
|
|
||||||
async fn run_installer(app_handle: tauri::AppHandle, args: String) -> anyhow::Result<()> {
|
async fn run_installer(app_handle: tauri::AppHandle, args: String) -> anyhow::Result<()> {
|
||||||
let args_vec = shlex::split(&args).context("Failed to parse arguments: unclosed quote")?;
|
let args_vec = shlex::split(&args).context("Failed to parse arguments: unclosed quote")?;
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
@@ -25,11 +33,17 @@ async fn install_rayhunter(app_handle: tauri::AppHandle, args: String) -> Result
|
|||||||
.map_err(|error| format!("{error:?}"))
|
.map_err(|error| format!("{error:?}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn rayhunter_options() -> introspect::Command<'static> {
|
||||||
|
introspect::Command::new(&INSTALLER_COMMAND)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.invoke_handler(tauri::generate_handler![install_rayhunter])
|
.invoke_handler(tauri::generate_handler![install_rayhunter])
|
||||||
|
.invoke_handler(tauri::generate_handler![rayhunter_options])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
//! Adds or "modifies" installer CLI attributes for use in the GUI.
|
||||||
|
//!
|
||||||
|
//! This module contains little logic (outside of tests) and instead just provides additional
|
||||||
|
//! metadata about CLI commands and options for the GUI installer.
|
||||||
|
//!
|
||||||
|
//! If we like this approach, I think we should consider renaming this file something like
|
||||||
|
//! gui_modifiers.rs and moving it into the crate for the CLI installer. I think this would simplify
|
||||||
|
//! development as any breaking changes to the CLI installer interface would cause tests to fail in
|
||||||
|
//! its own crate instead of installer-gui and it'd help to keep the two interfaces to the installer
|
||||||
|
//! in sync.
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct ArgumentModifier<'a> {
|
||||||
|
/// The name or "ID" of the argument as defined in clap. This will usually be the name of the
|
||||||
|
/// field in the struct the argument is derived from.
|
||||||
|
pub clap_id: &'a str,
|
||||||
|
/// The text for displaying this argument in the GUI.
|
||||||
|
pub gui_label: &'a str,
|
||||||
|
/// Whether this argument should be hidden behind a menu for "advanced" options.
|
||||||
|
pub advanced: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct SubcommandModifier<'a> {
|
||||||
|
/// The name of the subcommand on the CLI.
|
||||||
|
pub command: &'a str,
|
||||||
|
/// The text for displaying this subcommand in the GUI.
|
||||||
|
pub gui_label: &'a str,
|
||||||
|
/// Modifications to the arguments of this subcommand. The order arguments are defined in this
|
||||||
|
/// vector will match the order the arguments are displayed in the GUI.
|
||||||
|
pub arg_modifiers: Vec<ArgumentModifier<'a>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provides "modifiers" or additional metadata about each subcommand.
|
||||||
|
///
|
||||||
|
/// The order of the subcommands in the returned vector is the same order that subcommands will be
|
||||||
|
/// shown in the GUI. Similarly, the order of the elements in each arg_modifiers field controls the
|
||||||
|
/// order that a subcommand's options will be shown in the GUI.
|
||||||
|
pub fn subcommand_modifiers() -> Vec<SubcommandModifier<'static>> {
|
||||||
|
let admin_ip = ArgumentModifier {
|
||||||
|
clap_id: "admin_ip",
|
||||||
|
gui_label: "Admin IP",
|
||||||
|
advanced: true,
|
||||||
|
};
|
||||||
|
let admin_username = ArgumentModifier {
|
||||||
|
clap_id: "admin_username",
|
||||||
|
gui_label: "Admin Username",
|
||||||
|
advanced: true,
|
||||||
|
};
|
||||||
|
let admin_password = ArgumentModifier {
|
||||||
|
clap_id: "admin_password",
|
||||||
|
gui_label: "Admin Password",
|
||||||
|
advanced: false,
|
||||||
|
};
|
||||||
|
let data_dir = ArgumentModifier {
|
||||||
|
clap_id: "data_dir",
|
||||||
|
gui_label: "Data Directory",
|
||||||
|
advanced: true,
|
||||||
|
};
|
||||||
|
let reset_config = ArgumentModifier {
|
||||||
|
clap_id: "reset_config",
|
||||||
|
gui_label: "Reset config.toml",
|
||||||
|
advanced: true,
|
||||||
|
};
|
||||||
|
let orbic_and_moxee_args = vec![
|
||||||
|
admin_password,
|
||||||
|
admin_ip,
|
||||||
|
admin_username,
|
||||||
|
reset_config,
|
||||||
|
data_dir,
|
||||||
|
];
|
||||||
|
|
||||||
|
vec![
|
||||||
|
SubcommandModifier {
|
||||||
|
command: "orbic",
|
||||||
|
gui_label: "Orbic/Kajeet (via network)",
|
||||||
|
arg_modifiers: orbic_and_moxee_args.clone(),
|
||||||
|
},
|
||||||
|
SubcommandModifier {
|
||||||
|
command: "orbic-usb",
|
||||||
|
gui_label: "Orbic/Kajeet (via legacy USB+ADB installer)",
|
||||||
|
arg_modifiers: vec![reset_config],
|
||||||
|
},
|
||||||
|
SubcommandModifier {
|
||||||
|
command: "tplink",
|
||||||
|
gui_label: "TP-Link",
|
||||||
|
arg_modifiers: vec![
|
||||||
|
admin_ip,
|
||||||
|
reset_config,
|
||||||
|
data_dir,
|
||||||
|
ArgumentModifier {
|
||||||
|
clap_id: "skip_sdcard",
|
||||||
|
gui_label: "Skip SD Card",
|
||||||
|
advanced: true,
|
||||||
|
},
|
||||||
|
ArgumentModifier {
|
||||||
|
clap_id: "sdcard_path",
|
||||||
|
gui_label: "SD Card Path",
|
||||||
|
advanced: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
SubcommandModifier {
|
||||||
|
command: "moxee",
|
||||||
|
gui_label: "Moxee",
|
||||||
|
arg_modifiers: orbic_and_moxee_args,
|
||||||
|
},
|
||||||
|
SubcommandModifier {
|
||||||
|
command: "pinephone",
|
||||||
|
gui_label: "PinePhone",
|
||||||
|
arg_modifiers: vec![],
|
||||||
|
},
|
||||||
|
SubcommandModifier {
|
||||||
|
command: "tmobile",
|
||||||
|
gui_label: "TMobile",
|
||||||
|
arg_modifiers: vec![admin_password, admin_ip],
|
||||||
|
},
|
||||||
|
SubcommandModifier {
|
||||||
|
command: "uz801",
|
||||||
|
gui_label: "UZ801",
|
||||||
|
arg_modifiers: vec![admin_ip],
|
||||||
|
},
|
||||||
|
SubcommandModifier {
|
||||||
|
command: "wingtech",
|
||||||
|
gui_label: "Wingtech",
|
||||||
|
arg_modifiers: vec![admin_password, admin_ip],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
//! Subcommands and arguments not returned from subcommand_modifiers() will be excluded from the
|
||||||
|
//! GUI. This is by design as it allows us to exclude things like some or all of the installer
|
||||||
|
//! utils from the GUI. The tests below help ensure that exclusions were done deliberately
|
||||||
|
//! rather than on accident.
|
||||||
|
use super::*;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Lists the subcommands that are purposefully excluded from subcommand_modifiers().
|
||||||
|
fn excluded_subcommands() -> Vec<&'static str> {
|
||||||
|
vec!["util"]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists the arguments that are purposefully excluded from subcommand_modifiers(). Items in the
|
||||||
|
/// list take the form of (subcommand, argument_id) tuples.
|
||||||
|
fn excluded_arguments() -> Vec<(&'static str, &'static str)> {
|
||||||
|
// if for example we wanted to exclude the "--admin-password" argument for "orbic", we'd
|
||||||
|
// return vec![("orbic", "admin_password")] here
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_subcommands_excluded_or_modified() {
|
||||||
|
let mut all_subcommands: Vec<&str> = crate::INSTALLER_COMMAND
|
||||||
|
.get_subcommands()
|
||||||
|
.map(|c| c.get_name())
|
||||||
|
.collect();
|
||||||
|
let mut excluded_or_modified_subcommands: Vec<&str> = subcommand_modifiers()
|
||||||
|
.into_iter()
|
||||||
|
.map(|m| m.command)
|
||||||
|
.chain(excluded_subcommands())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
all_subcommands.sort_unstable();
|
||||||
|
excluded_or_modified_subcommands.sort_unstable();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
all_subcommands, excluded_or_modified_subcommands,
|
||||||
|
"Every subcommand must be included exactly once in subcommand_modifiers() or excluded_subcommands()."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_arguments_excluded_or_modified() {
|
||||||
|
// create maps of subcommand name to lists of argument names
|
||||||
|
let all_args_for_nonexcluded_subcommands: HashMap<&str, Vec<&str>> =
|
||||||
|
nonexcluded_subcommand_objects()
|
||||||
|
.into_iter()
|
||||||
|
.map(|c| {
|
||||||
|
(
|
||||||
|
c.get_name(),
|
||||||
|
c.get_arguments().map(|a| a.get_id().as_str()).collect(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let modified_args: HashMap<&str, Vec<&str>> = subcommand_modifiers()
|
||||||
|
.into_iter()
|
||||||
|
.map(|m| {
|
||||||
|
(
|
||||||
|
m.command,
|
||||||
|
m.arg_modifiers
|
||||||
|
.into_iter()
|
||||||
|
.map(|arg_m| arg_m.clap_id)
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// add excluded_arguments to modified_args
|
||||||
|
let mut excluded_or_modified_args = modified_args;
|
||||||
|
for (subcommand_name, arg_name) in excluded_arguments() {
|
||||||
|
excluded_or_modified_args
|
||||||
|
.entry(subcommand_name)
|
||||||
|
.or_default()
|
||||||
|
.push(arg_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// assert that all arguments are excluded or modified
|
||||||
|
for (subcommand_name, mut expected_args) in all_args_for_nonexcluded_subcommands {
|
||||||
|
let mut found_args = excluded_or_modified_args
|
||||||
|
.remove(subcommand_name)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
expected_args.sort_unstable();
|
||||||
|
found_args.sort_unstable();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
expected_args, found_args,
|
||||||
|
"Excluded and modified arguments differ from expected arguments for {subcommand_name}."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
excluded_or_modified_args.is_empty(),
|
||||||
|
"Excluded or modified arguments found for unexpected subcommands. Map of unexpected arguments is {:?}",
|
||||||
|
excluded_or_modified_args
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_arguments_have_long_flag() {
|
||||||
|
// any arguments without a long form command line flag will be excluded from the GUI so
|
||||||
|
// let's test for it here to avoid surprises
|
||||||
|
|
||||||
|
let excluded_args = excluded_arguments();
|
||||||
|
let nonexcluded_args: Vec<(&str, &clap::Arg)> = nonexcluded_subcommand_objects()
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|c| {
|
||||||
|
c.get_arguments().filter_map(|a| {
|
||||||
|
let subcommand_name = c.get_name();
|
||||||
|
|
||||||
|
if excluded_args.contains(&(subcommand_name, a.get_id().as_str())) {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some((subcommand_name, a))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (subcommand_name, arg) in nonexcluded_args {
|
||||||
|
assert!(
|
||||||
|
arg.get_long().is_some(),
|
||||||
|
"The {} argument for {subcommand_name} is missing a long form command line flag.",
|
||||||
|
arg.get_id().as_str()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nonexcluded_subcommand_objects() -> Vec<&'static clap::Command> {
|
||||||
|
let excluded_subcommands = excluded_subcommands();
|
||||||
|
|
||||||
|
crate::INSTALLER_COMMAND
|
||||||
|
.get_subcommands()
|
||||||
|
.filter(|s| !excluded_subcommands.contains(&s.get_name()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "installer"
|
name = "installer"
|
||||||
version = "0.11.0"
|
version = "0.11.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|||||||
+19
-1
@@ -28,7 +28,10 @@ fn set_binary_var(include_dir: &Path, var: &str, file: &str) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let binary = include_dir.join(file);
|
let binary = include_dir.join(file);
|
||||||
println!("cargo::rerun-if-changed={}", binary.display());
|
// We need to rerun the build script if the file starts appearing or disappearing, to emit the
|
||||||
|
// right warnings and change the envvar's value. We don't really need to rerun the build script
|
||||||
|
// if the file changes contents.
|
||||||
|
watch_file(&binary);
|
||||||
if binary.exists() {
|
if binary.exists() {
|
||||||
println!("cargo::rustc-env={var}={}", binary.display());
|
println!("cargo::rustc-env={var}={}", binary.display());
|
||||||
} else {
|
} else {
|
||||||
@@ -40,3 +43,18 @@ fn set_binary_var(include_dir: &Path, var: &str, file: &str) {
|
|||||||
println!("cargo::rustc-env={var}=");
|
println!("cargo::rustc-env={var}=");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rerun the build script if the file changes or it appears, or disappears.
|
||||||
|
///
|
||||||
|
/// Simply emitting rerun-if-changed for a nonexistent filepath (such as wpa_supplicant) will make
|
||||||
|
/// cargo recompile everything all the time. Therefore, if the file does not exist we need to watch
|
||||||
|
/// the first parent directory that does.
|
||||||
|
fn watch_file(mut file: &Path) {
|
||||||
|
while !file.exists()
|
||||||
|
&& let Some(parent) = file.parent()
|
||||||
|
{
|
||||||
|
file = parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("cargo::rerun-if-changed={}", file.display());
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,9 +30,11 @@ use crate::output::eprintln;
|
|||||||
static CONFIG_TOML: &str = include_str!("../../dist/config.toml.in");
|
static CONFIG_TOML: &str = include_str!("../../dist/config.toml.in");
|
||||||
static RAYHUNTER_DAEMON_INIT: &str = include_str!("../../dist/scripts/rayhunter_daemon");
|
static RAYHUNTER_DAEMON_INIT: &str = include_str!("../../dist/scripts/rayhunter_daemon");
|
||||||
|
|
||||||
|
// We mark this as public so it can be used by installer-gui to programmatically introspect the
|
||||||
|
// installer's options.
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(version, about)]
|
#[command(version, about)]
|
||||||
struct Args {
|
pub struct Args {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Command,
|
command: Command,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -346,9 +346,13 @@ async fn tplink_launch_telnet_v5(admin_ip: &str) -> Result<(), Error> {
|
|||||||
admin_ip: admin_ip.to_owned(),
|
admin_ip: admin_ip.to_owned(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:4000")
|
let bind_addr = "127.0.0.1:4000";
|
||||||
|
let listener = tokio::net::TcpListener::bind(bind_addr)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.with_context(|| format!(
|
||||||
|
"Failed to bind to {bind_addr}. Is another process using this port?\n\
|
||||||
|
Try closing any application that might be listening on port 4000 and rerun the installer."
|
||||||
|
))?;
|
||||||
|
|
||||||
println!("Listening on http://{}", listener.local_addr().unwrap());
|
println!("Listening on http://{}", listener.local_addr().unwrap());
|
||||||
println!("Please open above URL in your browser and log into the router to continue.");
|
println!("Please open above URL in your browser and log into the router to continue.");
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rayhunter"
|
name = "rayhunter"
|
||||||
version = "0.11.0"
|
version = "0.11.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Realtime cellular data decoding and analysis for IMSI catcher detection"
|
description = "Realtime cellular data decoding and analysis for IMSI catcher detection"
|
||||||
|
|
||||||
@@ -30,6 +30,7 @@ serde = { version = "1.0.197", features = ["derive"] }
|
|||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
num_enum = "0.7.4"
|
num_enum = "0.7.4"
|
||||||
utoipa = { version = "5.4.0", optional = true }
|
utoipa = { version = "5.4.0", optional = true }
|
||||||
|
async-compression = { version = "0.4.41", features = ["tokio", "gzip"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
|
|
||||||
use crate::analysis::diagnostic::DiagnosticAnalyzer;
|
use crate::analysis::diagnostic::DiagnosticAnalyzer;
|
||||||
use crate::gsmtap::{GsmtapHeader, GsmtapMessage, GsmtapType};
|
use crate::diag::diaglog::LogBody;
|
||||||
|
use crate::diag::{DiagParsingError, Message, MessagesContainer};
|
||||||
|
use crate::gsmtap::{GsmtapHeader, GsmtapMessage, GsmtapType, parser as gsmtap_parser};
|
||||||
use crate::util::RuntimeMetadata;
|
use crate::util::RuntimeMetadata;
|
||||||
use crate::{diag::MessagesContainer, gsmtap_parser};
|
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
connection_redirect_downgrade::ConnectionRedirect2GDowngradeAnalyzer,
|
connection_redirect_downgrade::ConnectionRedirect2GDowngradeAnalyzer,
|
||||||
@@ -223,7 +224,7 @@ impl AnalysisLineNormalizer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Debug)]
|
#[derive(Serialize, Debug, Default)]
|
||||||
pub struct AnalysisRow {
|
pub struct AnalysisRow {
|
||||||
pub packet_timestamp: Option<DateTime<FixedOffset>>,
|
pub packet_timestamp: Option<DateTime<FixedOffset>>,
|
||||||
pub skipped_message_reason: Option<String>,
|
pub skipped_message_reason: Option<String>,
|
||||||
@@ -231,6 +232,10 @@ pub struct AnalysisRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AnalysisRow {
|
impl AnalysisRow {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
self.skipped_message_reason.is_none() && !self.contains_warnings()
|
self.skipped_message_reason.is_none() && !self.contains_warnings()
|
||||||
}
|
}
|
||||||
@@ -412,50 +417,61 @@ impl Harness {
|
|||||||
row
|
row
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn analyze_qmdl_messages(&mut self, container: MessagesContainer) -> Vec<AnalysisRow> {
|
pub fn analyze_qmdl_message(
|
||||||
let mut rows = Vec::new();
|
&mut self,
|
||||||
for maybe_qmdl_message in container.into_messages() {
|
maybe_qmdl_message: Result<Message, DiagParsingError>,
|
||||||
|
) -> AnalysisRow {
|
||||||
|
let mut row = AnalysisRow::new();
|
||||||
self.packet_num += 1;
|
self.packet_num += 1;
|
||||||
|
|
||||||
rows.push(AnalysisRow {
|
|
||||||
packet_timestamp: None,
|
|
||||||
skipped_message_reason: None,
|
|
||||||
events: Vec::new(),
|
|
||||||
});
|
|
||||||
// unwrap is safe here since we just pushed a value
|
|
||||||
let row = rows.last_mut().unwrap();
|
|
||||||
let qmdl_message = match maybe_qmdl_message {
|
let qmdl_message = match maybe_qmdl_message {
|
||||||
Ok(msg) => msg,
|
Ok(msg) => msg,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
row.skipped_message_reason = Some(format!("{err:?}"));
|
row.skipped_message_reason = Some(format!("{err:?}"));
|
||||||
continue;
|
return row;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
if let Message::Log { body, .. } = &qmdl_message {
|
||||||
let gsmtap_message = match gsmtap_parser::parse(qmdl_message) {
|
match body {
|
||||||
Ok(msg) => msg,
|
LogBody::LteLl1ServingCellTiming { data } => {
|
||||||
|
if data.starting_ul_timing_advance != 0 {
|
||||||
|
println!("ta {}", data.starting_ul_timing_advance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LogBody::LteMl1ServingCellMeasurementAndEvaluation { data } => {
|
||||||
|
// println!("serving cell pci {}, earfcn {}", data.get_pci(), data.get_earfcn());
|
||||||
|
}
|
||||||
|
_ => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (timestamp, gsmtap_msg) = match gsmtap_parser::parse(qmdl_message) {
|
||||||
|
Ok(Some(msg)) => msg,
|
||||||
|
Ok(None) => return row,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
row.skipped_message_reason = Some(format!("{err:?}"));
|
row.skipped_message_reason = Some(format!("{err:?}"));
|
||||||
continue;
|
return row;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some((timestamp, gsmtap_msg)) = gsmtap_message else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
row.packet_timestamp = Some(timestamp.to_datetime());
|
row.packet_timestamp = Some(timestamp.to_datetime());
|
||||||
|
|
||||||
let element = match InformationElement::try_from(&gsmtap_msg) {
|
let element = match InformationElement::try_from(&gsmtap_msg) {
|
||||||
Ok(element) => element,
|
Ok(element) => element,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
row.skipped_message_reason = Some(format!("{err:?}"));
|
row.skipped_message_reason = Some(format!("{err:?}"));
|
||||||
continue;
|
return row;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
row.events = self.analyze_information_element(&element);
|
row.events = self.analyze_information_element(&element);
|
||||||
|
row
|
||||||
}
|
}
|
||||||
rows
|
|
||||||
|
pub fn analyze_qmdl_messages(&mut self, container: MessagesContainer) -> Vec<AnalysisRow> {
|
||||||
|
container
|
||||||
|
.messages()
|
||||||
|
.drain(..)
|
||||||
|
.map(|maybe_message| self.analyze_qmdl_message(maybe_message))
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn analyze_information_element(&mut self, ie: &InformationElement) -> Vec<Option<Event>> {
|
fn analyze_information_element(&mut self, ie: &InformationElement) -> Vec<Option<Event>> {
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
use deku::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||||
|
#[deku(bit_order = "lsb", ctx = "_: deku::ctx::Order")]
|
||||||
|
pub struct ServingCellTiming {
|
||||||
|
#[deku(assert_eq = "1")]
|
||||||
|
pub version: u8,
|
||||||
|
#[deku(bits = 5, assert = "*num_records <= 20")]
|
||||||
|
pub num_records: u8,
|
||||||
|
#[deku(bits = 4, assert = "*starting_sub_fn <= 9")]
|
||||||
|
pub starting_sub_fn: u8,
|
||||||
|
#[deku(bits = 10, pad_bits_after = "5", assert = "*starting_system_fn <= 1023")]
|
||||||
|
pub starting_system_fn: u16,
|
||||||
|
#[deku(bits = 19, pad_bits_after = "13", assert = "*starting_dl_frame_timing_offs <= 307200")]
|
||||||
|
pub starting_dl_frame_timing_offs: u32, // in Ts units
|
||||||
|
#[deku(bits = 19, assert = "*starting_ul_frame_timing_offs <= 307200")]
|
||||||
|
pub starting_ul_frame_timing_offs: u32, // in Ts units
|
||||||
|
#[deku(bits = 11, pad_bits_after = "2")]
|
||||||
|
pub starting_ul_timing_advance: u16, // in 16 Ts units
|
||||||
|
#[deku(count = "*num_records")]
|
||||||
|
pub timing_adjustment: Vec<TimingAdjustment>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||||
|
#[deku(bit_order = "lsb", ctx = "_: deku::ctx::Order")]
|
||||||
|
pub struct TimingAdjustment {
|
||||||
|
#[deku(bits = 11, assert = "(-512..=511).contains(dl_frame_timing_adjustment)")]
|
||||||
|
pub dl_frame_timing_adjustment: i16, // in Ts units
|
||||||
|
#[deku(bits = 5, assert = "(-16..=15).contains(ul_frame_timing_adjustment)")]
|
||||||
|
pub ul_frame_timing_adjustment: i8, // in Ts units
|
||||||
|
#[deku(bits = 8, assert = "(-128..=127).contains(timing_advance)")]
|
||||||
|
pub timing_advance: i8, // in 16 Ts units
|
||||||
|
}
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
//! Diag MAC RACH serialization/deserialization. As with most of our diag
|
||||||
|
//! parsers, these structs were derived SCAT:
|
||||||
|
//! https://github.com/fgsect/scat/blob/9763cb5b1dcd5ee980f5b0ead9a8d520c8c51a51/src/scat/parsers/qualcomm/diagltelogparser.py#L853
|
||||||
|
|
||||||
|
use deku::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DekuRead, DekuWrite, Debug, Clone, PartialEq)]
|
||||||
|
pub struct Packet {
|
||||||
|
#[deku(assert_eq = "1")]
|
||||||
|
pub version: u8,
|
||||||
|
pub num_subpackets: u8,
|
||||||
|
#[deku(pad_bytes_before = "2", count = "*num_subpackets")]
|
||||||
|
pub subpackets: Vec<Subpacket>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DekuRead, DekuWrite, Debug, Clone, PartialEq)]
|
||||||
|
pub struct Subpacket {
|
||||||
|
pub id: u8,
|
||||||
|
pub version: u8,
|
||||||
|
pub size: u16,
|
||||||
|
// size includes the header length, so subtract that
|
||||||
|
#[deku(ctx = "*id, *version, *size - 4")]
|
||||||
|
pub body: SubpacketBody,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DekuRead, DekuWrite, Debug, Clone, PartialEq)]
|
||||||
|
#[deku(ctx = "id: u8, version: u8, size: u16", id = "id")]
|
||||||
|
pub enum SubpacketBody {
|
||||||
|
#[deku(id = 0x06)]
|
||||||
|
RachAttempt(#[deku(ctx = "version")] rach::Attempt),
|
||||||
|
#[deku(id_pat = "_")]
|
||||||
|
Other {
|
||||||
|
#[deku(count = "size")]
|
||||||
|
data: Vec<u8>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod rach {
|
||||||
|
//! Derived from https://github.com/fgsect/scat/blob/9763cb5b1dcd5ee980f5b0ead9a8d520c8c51a51/src/scat/parsers/qualcomm/diagltelogparser.py#L496
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(DekuRead, DekuWrite, Debug, Clone, PartialEq)]
|
||||||
|
#[deku(ctx = "version: u8")]
|
||||||
|
pub struct Attempt {
|
||||||
|
#[deku(ctx = "version")]
|
||||||
|
pub header: AttemptHeader,
|
||||||
|
#[deku(ctx = "version")]
|
||||||
|
pub msg1: Msg1,
|
||||||
|
pub msg2: Msg2,
|
||||||
|
#[deku(ctx = "version")]
|
||||||
|
pub msg3: Msg3,
|
||||||
|
#[deku(cond = "version == 0x31 || version == 0x32")]
|
||||||
|
pub additional_info: Option<AdditionalInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Attempt {
|
||||||
|
pub fn get_msg1(&self) -> Option<&Msg1> {
|
||||||
|
if self.header.has_msg1() {
|
||||||
|
Some(&self.msg1)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_msg2(&self) -> Option<&Msg2> {
|
||||||
|
if self.header.has_msg2() {
|
||||||
|
Some(&self.msg2)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_msg3(&self) -> Option<&Msg3> {
|
||||||
|
if self.header.has_msg3() {
|
||||||
|
Some(&self.msg3)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DekuRead, DekuWrite, Debug, Clone, PartialEq)]
|
||||||
|
pub struct AdditionalInfo {
|
||||||
|
pub ul_earfcn: u32,
|
||||||
|
pub p_max: u8,
|
||||||
|
pub scell_id: u8,
|
||||||
|
pub unk1: u32,
|
||||||
|
pub unk2: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DekuRead, DekuWrite, Debug, Clone, PartialEq)]
|
||||||
|
#[deku(ctx = "version: u8", id = "version")]
|
||||||
|
pub enum Msg1 {
|
||||||
|
#[deku(id = "0x02")]
|
||||||
|
V2 {
|
||||||
|
preamble_index: u8,
|
||||||
|
preamble_index_mask: u8,
|
||||||
|
preamble_power_offset: i16,
|
||||||
|
},
|
||||||
|
#[deku(id_pat = "0x03 | 0x31")]
|
||||||
|
V3Or31 {
|
||||||
|
preamble_index: u8,
|
||||||
|
preamble_index_mask: u8,
|
||||||
|
preamble_power_offset: i16,
|
||||||
|
},
|
||||||
|
#[deku(id = "0x32")]
|
||||||
|
V32 {
|
||||||
|
preamble_index: u8,
|
||||||
|
preamble_index_mask: u8,
|
||||||
|
preamble_power_offset: i16,
|
||||||
|
unk1: u16,
|
||||||
|
group: i8,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Msg1 {
|
||||||
|
pub fn get_preamble_index(&self) -> u8 {
|
||||||
|
match self {
|
||||||
|
Msg1::V2 { preamble_index, .. } => *preamble_index,
|
||||||
|
Msg1::V3Or31 { preamble_index, .. } => *preamble_index,
|
||||||
|
Msg1::V32 { preamble_index, .. } => *preamble_index,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DekuRead, DekuWrite, Debug, Clone, PartialEq)]
|
||||||
|
pub struct Msg2 {
|
||||||
|
pub backoff: u16,
|
||||||
|
pub result: u8,
|
||||||
|
pub tc_rnti: u16,
|
||||||
|
pub ta: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DekuRead, DekuWrite, Debug, Clone, PartialEq)]
|
||||||
|
#[deku(ctx = "version: u8")]
|
||||||
|
pub struct Msg3 {
|
||||||
|
#[deku(ctx = "version")]
|
||||||
|
pub grant: Msg3Grant,
|
||||||
|
pub unk_grant: u16,
|
||||||
|
pub harq_id: u8,
|
||||||
|
pub mac_pdu: [u8; 10],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Msg3 {
|
||||||
|
pub fn get_grant(&self) -> u32 {
|
||||||
|
match &self.grant {
|
||||||
|
Msg3Grant::V1 { grant } => *grant & 0xfffff,
|
||||||
|
Msg3Grant::V32 { grant } => *grant & 0xfffff,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DekuRead, DekuWrite, Debug, Clone, PartialEq)]
|
||||||
|
#[deku(ctx = "version: u8", id = "version")]
|
||||||
|
pub enum Msg3Grant {
|
||||||
|
#[deku(id_pat = "0..0x32")]
|
||||||
|
V1 {
|
||||||
|
#[deku(endian = "little")]
|
||||||
|
grant: u32,
|
||||||
|
},
|
||||||
|
#[deku(id_pat = "0x32..")]
|
||||||
|
V32 {
|
||||||
|
#[deku(endian = "big")]
|
||||||
|
grant: u32,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DekuRead, DekuWrite, Debug, Clone, PartialEq)]
|
||||||
|
#[deku(ctx = "version: u8", id = "version")]
|
||||||
|
pub enum AttemptHeader {
|
||||||
|
#[deku(id = 0x02)]
|
||||||
|
V2 {
|
||||||
|
num_attempt: u8,
|
||||||
|
rach_result: u8,
|
||||||
|
contention: u8,
|
||||||
|
msg_bitmask: u8,
|
||||||
|
},
|
||||||
|
#[deku(id_pat = "0x03 | 0x31 | 0x32")]
|
||||||
|
V3 {
|
||||||
|
sub_id: u8,
|
||||||
|
cell_id: u8,
|
||||||
|
num_attempt: u8,
|
||||||
|
rach_result: u8,
|
||||||
|
contention: u8,
|
||||||
|
msg_bitmask: u8,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AttemptHeader {
|
||||||
|
fn get_bitmask(&self) -> u8 {
|
||||||
|
match self {
|
||||||
|
AttemptHeader::V2 { msg_bitmask, .. } => *msg_bitmask,
|
||||||
|
AttemptHeader::V3 { msg_bitmask, .. } => *msg_bitmask,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_msg1(&self) -> bool {
|
||||||
|
self.get_bitmask() & 0x01 > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_msg2(&self) -> bool {
|
||||||
|
self.get_bitmask() & 0x02 > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_msg3(&self) -> bool {
|
||||||
|
self.get_bitmask() & 0x04 > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod test {
|
||||||
|
//! These tests were adapted from SCAT's MAC RACH parser's unit tests,
|
||||||
|
//! and the values were produced by modifying the tests to output the
|
||||||
|
//! entire parsed struct rather than the hexlified gsmtap packets. See
|
||||||
|
//! the changes in this commit for more info:
|
||||||
|
//! https://github.com/wgreenberg/scat/commit/adb21575832b4f3b30c8f2aaca9ee843ef74f38b
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::diag::diaglog::mac::rach::{AdditionalInfo, AttemptHeader, Msg1, Msg2, Msg3};
|
||||||
|
use crate::{diag::diaglog::mac::rach::Msg3Grant, test_util::unhexlify};
|
||||||
|
|
||||||
|
use std::io::Seek;
|
||||||
|
|
||||||
|
pub fn mac_rach_test_packets_from_scat() -> Vec<Packet> {
|
||||||
|
// test data from SCAT unit tests: https://github.com/fgsect/scat/blob/9763cb5b1dcd5ee980f5b0ead9a8d520c8c51a51/tests/test_diagltelogparser.py#L129
|
||||||
|
vec![
|
||||||
|
parse_rach_packet(
|
||||||
|
"0101a06906022400010001071BFF98FF000001231A0400181C010007000600465C80BD0648000000",
|
||||||
|
),
|
||||||
|
parse_rach_packet(
|
||||||
|
"0101a0690603280001000100010718ffa4ff000001c6610b00b4a2000012000120061f423f8d95075800",
|
||||||
|
),
|
||||||
|
parse_rach_packet(
|
||||||
|
"0101739e063134000100010000033f0098ff0000013c6b070058ac010007000000468f47e2d446000000644b0000180001000000d5040000",
|
||||||
|
),
|
||||||
|
parse_rach_packet(
|
||||||
|
"01010000063134000100010001070aff98ff0000011c48070018e2000007000000523b7dfd69b6000000f5540000ff0001000000d6040000",
|
||||||
|
),
|
||||||
|
parse_rach_packet(
|
||||||
|
"01010000063238000100010000032900a4ffeb000000000195b603000000a0b412000420061f425dc9be41b800885e000017000100000065050000",
|
||||||
|
),
|
||||||
|
parse_rach_packet(
|
||||||
|
"010100000632380001000100010713ffa0ffeb0000000001ad5a0500000146b412000420061f425dc9be41b400665300001800010000001a050000",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_rach_packet(bytes_str: &str) -> Packet {
|
||||||
|
let (total_size, mut reader) = unhexlify(bytes_str);
|
||||||
|
let packet = Packet::from_reader_with_ctx(&mut reader, ()).unwrap();
|
||||||
|
let leftover_bits = reader.rest().len();
|
||||||
|
let leftover_bytes = total_size - reader.stream_position().unwrap() as usize;
|
||||||
|
assert_eq!(leftover_bytes, 0);
|
||||||
|
assert_eq!(leftover_bits, 0);
|
||||||
|
packet
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_rach_subpacket(
|
||||||
|
packet: &Packet,
|
||||||
|
header: AttemptHeader,
|
||||||
|
msg1: Option<Msg1>,
|
||||||
|
msg2: Option<Msg2>,
|
||||||
|
msg3: Option<Msg3>,
|
||||||
|
additional_info: Option<AdditionalInfo>,
|
||||||
|
) {
|
||||||
|
assert_eq!(packet.version, 0x01);
|
||||||
|
assert_eq!(packet.num_subpackets, 1);
|
||||||
|
assert_eq!(packet.subpackets.len(), 1);
|
||||||
|
if let SubpacketBody::RachAttempt(attempt) = &packet.subpackets[0].body {
|
||||||
|
assert_eq!(attempt.header, header);
|
||||||
|
assert_eq!(attempt.get_msg1(), msg1.as_ref());
|
||||||
|
assert_eq!(attempt.get_msg2(), msg2.as_ref());
|
||||||
|
assert_eq!(attempt.get_msg3(), msg3.as_ref());
|
||||||
|
assert_eq!(attempt.additional_info, additional_info);
|
||||||
|
} else {
|
||||||
|
panic!("not rach attempt {:?}", packet.subpackets[0].body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rach_attempt_parsing() {
|
||||||
|
let test_packets = mac_rach_test_packets_from_scat();
|
||||||
|
assert_rach_subpacket(
|
||||||
|
&test_packets[0],
|
||||||
|
rach::AttemptHeader::V2 {
|
||||||
|
num_attempt: 1,
|
||||||
|
rach_result: 0,
|
||||||
|
contention: 1,
|
||||||
|
msg_bitmask: 7,
|
||||||
|
},
|
||||||
|
Some(Msg1::V2 {
|
||||||
|
preamble_index: 27,
|
||||||
|
preamble_index_mask: 255,
|
||||||
|
preamble_power_offset: -104,
|
||||||
|
}),
|
||||||
|
Some(Msg2 {
|
||||||
|
backoff: 0,
|
||||||
|
result: 1,
|
||||||
|
tc_rnti: 6691,
|
||||||
|
ta: 4,
|
||||||
|
}),
|
||||||
|
Some(Msg3 {
|
||||||
|
grant: Msg3Grant::V1 { grant: 72728 },
|
||||||
|
unk_grant: 7,
|
||||||
|
harq_id: 6,
|
||||||
|
mac_pdu: [0x00, 0x46, 0x5c, 0x80, 0xbd, 0x06, 0x48, 0x00, 0x00, 0x00],
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_rach_subpacket(
|
||||||
|
&test_packets[1],
|
||||||
|
rach::AttemptHeader::V3 {
|
||||||
|
sub_id: 1,
|
||||||
|
cell_id: 0,
|
||||||
|
num_attempt: 1,
|
||||||
|
rach_result: 0,
|
||||||
|
contention: 1,
|
||||||
|
msg_bitmask: 7,
|
||||||
|
},
|
||||||
|
Some(Msg1::V3Or31 {
|
||||||
|
preamble_index: 24,
|
||||||
|
preamble_index_mask: 255,
|
||||||
|
preamble_power_offset: -92,
|
||||||
|
}),
|
||||||
|
Some(Msg2 {
|
||||||
|
backoff: 0,
|
||||||
|
result: 1,
|
||||||
|
tc_rnti: 25030,
|
||||||
|
ta: 11,
|
||||||
|
}),
|
||||||
|
Some(Msg3 {
|
||||||
|
grant: Msg3Grant::V1 { grant: 41652 },
|
||||||
|
unk_grant: 18,
|
||||||
|
harq_id: 1,
|
||||||
|
mac_pdu: [0x20, 0x06, 0x1f, 0x42, 0x3f, 0x8d, 0x95, 0x07, 0x58, 0x00],
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_rach_subpacket(
|
||||||
|
&test_packets[2],
|
||||||
|
rach::AttemptHeader::V3 {
|
||||||
|
sub_id: 1,
|
||||||
|
cell_id: 0,
|
||||||
|
num_attempt: 1,
|
||||||
|
rach_result: 0,
|
||||||
|
contention: 0,
|
||||||
|
msg_bitmask: 3,
|
||||||
|
},
|
||||||
|
Some(Msg1::V3Or31 {
|
||||||
|
preamble_index: 63,
|
||||||
|
preamble_index_mask: 0,
|
||||||
|
preamble_power_offset: -104,
|
||||||
|
}),
|
||||||
|
Some(Msg2 {
|
||||||
|
backoff: 0,
|
||||||
|
result: 1,
|
||||||
|
tc_rnti: 27452,
|
||||||
|
ta: 7,
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
Some(AdditionalInfo {
|
||||||
|
ul_earfcn: 19300,
|
||||||
|
p_max: 24,
|
||||||
|
scell_id: 0,
|
||||||
|
unk1: 1,
|
||||||
|
unk2: 1237,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_rach_subpacket(
|
||||||
|
&test_packets[3],
|
||||||
|
AttemptHeader::V3 {
|
||||||
|
sub_id: 1,
|
||||||
|
cell_id: 0,
|
||||||
|
num_attempt: 1,
|
||||||
|
rach_result: 0,
|
||||||
|
contention: 1,
|
||||||
|
msg_bitmask: 7,
|
||||||
|
},
|
||||||
|
Some(Msg1::V3Or31 {
|
||||||
|
preamble_index: 10,
|
||||||
|
preamble_index_mask: 255,
|
||||||
|
preamble_power_offset: -104,
|
||||||
|
}),
|
||||||
|
Some(Msg2 {
|
||||||
|
backoff: 0,
|
||||||
|
result: 1,
|
||||||
|
tc_rnti: 18460,
|
||||||
|
ta: 7,
|
||||||
|
}),
|
||||||
|
Some(Msg3 {
|
||||||
|
grant: Msg3Grant::V1 { grant: 57880 },
|
||||||
|
unk_grant: 7,
|
||||||
|
harq_id: 0,
|
||||||
|
mac_pdu: [0x00, 0x52, 0x3b, 0x7d, 0xfd, 0x69, 0xb6, 0x00, 0x00, 0x00],
|
||||||
|
}),
|
||||||
|
Some(AdditionalInfo {
|
||||||
|
ul_earfcn: 21749,
|
||||||
|
p_max: 255,
|
||||||
|
scell_id: 0,
|
||||||
|
unk1: 1,
|
||||||
|
unk2: 1238,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_rach_subpacket(
|
||||||
|
&test_packets[4],
|
||||||
|
AttemptHeader::V3 {
|
||||||
|
sub_id: 1,
|
||||||
|
cell_id: 0,
|
||||||
|
num_attempt: 1,
|
||||||
|
rach_result: 0,
|
||||||
|
contention: 0,
|
||||||
|
msg_bitmask: 3,
|
||||||
|
},
|
||||||
|
Some(Msg1::V32 {
|
||||||
|
preamble_index: 41,
|
||||||
|
preamble_index_mask: 0,
|
||||||
|
preamble_power_offset: -92,
|
||||||
|
unk1: 235,
|
||||||
|
group: 0,
|
||||||
|
}),
|
||||||
|
Some(Msg2 {
|
||||||
|
backoff: 0,
|
||||||
|
result: 1,
|
||||||
|
tc_rnti: 46741,
|
||||||
|
ta: 3,
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
Some(AdditionalInfo {
|
||||||
|
ul_earfcn: 24200,
|
||||||
|
p_max: 23,
|
||||||
|
scell_id: 0,
|
||||||
|
unk1: 1,
|
||||||
|
unk2: 1381,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_rach_subpacket(
|
||||||
|
&test_packets[5],
|
||||||
|
AttemptHeader::V3 {
|
||||||
|
sub_id: 1,
|
||||||
|
cell_id: 0,
|
||||||
|
num_attempt: 1,
|
||||||
|
rach_result: 0,
|
||||||
|
contention: 1,
|
||||||
|
msg_bitmask: 7,
|
||||||
|
},
|
||||||
|
Some(Msg1::V32 {
|
||||||
|
preamble_index: 19,
|
||||||
|
preamble_index_mask: 255,
|
||||||
|
preamble_power_offset: -96,
|
||||||
|
unk1: 235,
|
||||||
|
group: 0,
|
||||||
|
}),
|
||||||
|
Some(Msg2 {
|
||||||
|
backoff: 0,
|
||||||
|
result: 1,
|
||||||
|
tc_rnti: 23213,
|
||||||
|
ta: 5,
|
||||||
|
}),
|
||||||
|
Some(Msg3 {
|
||||||
|
grant: Msg3Grant::V32 { grant: 83636 },
|
||||||
|
unk_grant: 18,
|
||||||
|
harq_id: 4,
|
||||||
|
mac_pdu: [0x20, 0x06, 0x1f, 0x42, 0x5d, 0xc9, 0xbe, 0x41, 0xb4, 0x00],
|
||||||
|
}),
|
||||||
|
Some(AdditionalInfo {
|
||||||
|
ul_earfcn: 21350,
|
||||||
|
p_max: 24,
|
||||||
|
scell_id: 0,
|
||||||
|
unk1: 1,
|
||||||
|
unk2: 1306,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
//! Diag ML1 measurement log serialization/deserialization. As with most of our
|
||||||
|
//! diag parsers, these structs were derived SCAT:
|
||||||
|
//! Neighbor cell measurements: https://github.com/fgsect/scat/blob/9763cb5b1dcd5ee980f5b0ead9a8d520c8c51a51/src/scat/parsers/qualcomm/diagltelogparser.py#L192
|
||||||
|
//! Serving cell measurements: https://github.com/fgsect/scat/blob/9763cb5b1dcd5ee980f5b0ead9a8d520c8c51a51/src/scat/parsers/qualcomm/diagltelogparser.py#L114
|
||||||
|
|
||||||
|
use deku::ctx::Order;
|
||||||
|
use deku::prelude::*;
|
||||||
|
|
||||||
|
fn decode_rsrp(rsrp: u16) -> f32 {
|
||||||
|
rsrp as f32 / 16.0 - 180.0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_rssi(rssi: u16) -> f32 {
|
||||||
|
rssi as f32 / 16.0 - 110.0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_rsrq(rsrq: u16) -> f32 {
|
||||||
|
rsrq as f32 / 16.0 - 30.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod serving_cell {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||||
|
#[deku(bit_order = "lsb")]
|
||||||
|
pub struct MeasurementAndEvaluation {
|
||||||
|
pub header: MeasurementAndEvaluationHeader,
|
||||||
|
#[deku(bits = 12, pad_bits_after = "20")]
|
||||||
|
meas_rsrp: u16,
|
||||||
|
avg_rsrp: u32,
|
||||||
|
#[deku(bits = 10, pad_bits_after = "22")]
|
||||||
|
meas_rsrq: u16,
|
||||||
|
#[deku(pad_bits_before = "10", bits = 11, pad_bits_after = "11")]
|
||||||
|
meas_rssi: u16,
|
||||||
|
rxlev: u32,
|
||||||
|
s_search: u32,
|
||||||
|
#[deku(cond = "header.get_rrc_rel() == 0x01")]
|
||||||
|
r9_data: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||||
|
#[deku(ctx = "_: Order", id_type = "u8", bit_order = "lsb")]
|
||||||
|
pub enum MeasurementAndEvaluationHeader {
|
||||||
|
#[deku(id = "4")]
|
||||||
|
V4 {
|
||||||
|
rrc_rel: u8,
|
||||||
|
_reserved: u16,
|
||||||
|
earfcn: u16,
|
||||||
|
#[deku(bits = 9)]
|
||||||
|
pci: u16,
|
||||||
|
#[deku(bits = 7)]
|
||||||
|
serv_layer_priority: u8,
|
||||||
|
},
|
||||||
|
#[deku(id = "5")]
|
||||||
|
V5 {
|
||||||
|
rrc_rel: u8,
|
||||||
|
_reserved: u16,
|
||||||
|
earfcn: u32,
|
||||||
|
#[deku(bits = 9)]
|
||||||
|
pci: u16,
|
||||||
|
#[deku(bits = 7, pad_bytes_after = "2")]
|
||||||
|
serv_layer_priority: u8,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MeasurementAndEvaluationHeader {
|
||||||
|
fn get_rrc_rel(&self) -> u8 {
|
||||||
|
match self {
|
||||||
|
MeasurementAndEvaluationHeader::V4 { rrc_rel, .. } => *rrc_rel,
|
||||||
|
MeasurementAndEvaluationHeader::V5 { rrc_rel, .. } => *rrc_rel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MeasurementAndEvaluation {
|
||||||
|
pub fn get_pci(&self) -> u16 {
|
||||||
|
match &self.header {
|
||||||
|
MeasurementAndEvaluationHeader::V4 { pci, .. } => *pci,
|
||||||
|
MeasurementAndEvaluationHeader::V5 { pci, .. } => *pci,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_earfcn(&self) -> u32 {
|
||||||
|
match &self.header {
|
||||||
|
MeasurementAndEvaluationHeader::V4 { earfcn, .. } => *earfcn as u32,
|
||||||
|
MeasurementAndEvaluationHeader::V5 { earfcn, .. } => *earfcn,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_meas_rsrp(&self) -> f32 {
|
||||||
|
decode_rsrp(self.meas_rsrp)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_meas_rssi(&self) -> f32 {
|
||||||
|
decode_rssi(self.meas_rssi)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_meas_rsrq(&self) -> f32 {
|
||||||
|
decode_rsrq(self.meas_rsrq)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod neighbor_cells {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, DekuRead, DekuWrite, PartialEq)]
|
||||||
|
#[deku(id_type = "u8", bit_order = "lsb")]
|
||||||
|
pub enum MeasurementsHeader {
|
||||||
|
#[deku(id = "4")]
|
||||||
|
V4 {
|
||||||
|
rrc_rel: u8,
|
||||||
|
_reserved1: u16,
|
||||||
|
earfcn: u16,
|
||||||
|
#[deku(bits = 6)]
|
||||||
|
q_rxlevmin: u8,
|
||||||
|
#[deku(bits = 10)]
|
||||||
|
n_cells: u16,
|
||||||
|
},
|
||||||
|
#[deku(id = "5")]
|
||||||
|
V5 {
|
||||||
|
rrc_rel: u8,
|
||||||
|
_reserved1: u16,
|
||||||
|
earfcn: u32,
|
||||||
|
#[deku(bits = 6)]
|
||||||
|
q_rxlevmin: u8,
|
||||||
|
#[deku(bits = 26)]
|
||||||
|
n_cells: u32,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MeasurementsHeader {
|
||||||
|
fn get_n_cells(&self) -> usize {
|
||||||
|
match self {
|
||||||
|
MeasurementsHeader::V4 { n_cells, .. } => *n_cells as usize,
|
||||||
|
MeasurementsHeader::V5 { n_cells, .. } => *n_cells as usize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, DekuRead, DekuWrite, PartialEq)]
|
||||||
|
pub struct Measurements {
|
||||||
|
pub header: MeasurementsHeader,
|
||||||
|
#[deku(count = "header.get_n_cells()")]
|
||||||
|
pub cells: Vec<MeasurementsCell>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Measurements {
|
||||||
|
pub fn get_earfcn(&self) -> u32 {
|
||||||
|
match &self.header {
|
||||||
|
MeasurementsHeader::V4 { earfcn, .. } => *earfcn as u32,
|
||||||
|
MeasurementsHeader::V5 { earfcn, .. } => *earfcn,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, DekuRead, DekuWrite, PartialEq)]
|
||||||
|
#[deku(bit_order = "lsb")]
|
||||||
|
pub struct MeasurementsCell {
|
||||||
|
#[deku(bits = 9)]
|
||||||
|
pub pci: u16,
|
||||||
|
#[deku(bits = 11)]
|
||||||
|
meas_rssi: u16,
|
||||||
|
#[deku(bits = 12)]
|
||||||
|
meas_rsrp: u16,
|
||||||
|
#[deku(pad_bits_before = "12", bits = 12, pad_bits_after = "8")]
|
||||||
|
avg_rsrp: u16,
|
||||||
|
#[deku(pad_bits_before = "12", bits = 10, pad_bits_after = "10")]
|
||||||
|
meas_rsrq: u16,
|
||||||
|
#[deku(bits = 10, pad_bits_after = "10")]
|
||||||
|
avg_rsrq: u16,
|
||||||
|
#[deku(bits = 6, pad_bits_after = "6")]
|
||||||
|
s_rxlev: u16,
|
||||||
|
n_freq_offset: u16,
|
||||||
|
val5: u16,
|
||||||
|
ant0_offset: u32,
|
||||||
|
ant1_offset: u32,
|
||||||
|
unk1: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MeasurementsCell {
|
||||||
|
pub fn get_meas_rsrp(&self) -> f32 {
|
||||||
|
decode_rsrp(self.meas_rsrp)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_meas_rssi(&self) -> f32 {
|
||||||
|
decode_rssi(self.meas_rssi)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_meas_rsrq(&self) -> f32 {
|
||||||
|
decode_rsrq(self.meas_rsrq)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
//! The tests for serving cell/neighbor cell measurements were adapted from
|
||||||
|
//! SCAT's tests. The expected values were collected by modifying SCAT to
|
||||||
|
//! print out the full-precision expected values. See:
|
||||||
|
//! https://github.com/wgreenberg/scat/commit/e53d657861e8a66b52d635ff9518ac896c23ab06
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::diag::diaglog::LogBody;
|
||||||
|
use crate::log_codes::{LOG_LTE_ML1_NEIGHBOR_MEAS, LOG_LTE_ML1_SERVING_CELL_MEAS_AND_EVAL_C};
|
||||||
|
use crate::test_util::unhexlify;
|
||||||
|
use std::io::Seek;
|
||||||
|
|
||||||
|
fn parse_ncell_measurements(hexlified_bytes: &str) -> (u8, neighbor_cells::Measurements) {
|
||||||
|
let (total_size, mut reader) = unhexlify(hexlified_bytes);
|
||||||
|
match LogBody::from_reader_with_ctx(&mut reader, (LOG_LTE_ML1_NEIGHBOR_MEAS as u16, 0)) {
|
||||||
|
Ok(LogBody::LteMl1NeighborCellsMeasurements { data }) => {
|
||||||
|
if !reader.end() {
|
||||||
|
let leftover_bits = reader.rest();
|
||||||
|
let leftover_bytes = total_size - reader.stream_position().unwrap() as usize;
|
||||||
|
panic!(
|
||||||
|
"failed to read entire buffer ({} bytes, {} bits left)",
|
||||||
|
leftover_bytes,
|
||||||
|
leftover_bits.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let pkt_version = match data.header {
|
||||||
|
neighbor_cells::MeasurementsHeader::V4 { .. } => 4,
|
||||||
|
neighbor_cells::MeasurementsHeader::V5 { .. } => 5,
|
||||||
|
};
|
||||||
|
(pkt_version, data)
|
||||||
|
}
|
||||||
|
Ok(x) => panic!("expected MeasurementAndEvaluation, but parsed {:?}", x),
|
||||||
|
Err(x) => panic!("failed to parse MeasurementAndEvaluation {:?}", x),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_meas_eval(hexlified_bytes: &str) -> (u8, serving_cell::MeasurementAndEvaluation) {
|
||||||
|
let (total_size, mut reader) = unhexlify(hexlified_bytes);
|
||||||
|
match LogBody::from_reader_with_ctx(
|
||||||
|
&mut reader,
|
||||||
|
(LOG_LTE_ML1_SERVING_CELL_MEAS_AND_EVAL_C as u16, 0),
|
||||||
|
) {
|
||||||
|
Ok(LogBody::LteMl1ServingCellMeasurementAndEvaluation { data }) => {
|
||||||
|
if !reader.end() {
|
||||||
|
let leftover_bits = reader.rest();
|
||||||
|
let leftover_bytes = total_size - reader.stream_position().unwrap() as usize;
|
||||||
|
panic!(
|
||||||
|
"failed to read entire buffer ({} bytes, {} bits left)",
|
||||||
|
leftover_bytes,
|
||||||
|
leftover_bits.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let pkt_version = match data.header {
|
||||||
|
serving_cell::MeasurementAndEvaluationHeader::V4 { .. } => 4,
|
||||||
|
serving_cell::MeasurementAndEvaluationHeader::V5 { .. } => 5,
|
||||||
|
};
|
||||||
|
(pkt_version, data)
|
||||||
|
}
|
||||||
|
Ok(x) => panic!("expected MeasurementAndEvaluation, but parsed {:?}", x),
|
||||||
|
Err(x) => panic!("failed to parse MeasurementAndEvaluation {:?}", x),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scell_meas_and_eval_case(
|
||||||
|
hexlified_bytes: &str,
|
||||||
|
pkt_version: u8,
|
||||||
|
pci: u16,
|
||||||
|
earfcn: u32,
|
||||||
|
rsrp: f32,
|
||||||
|
rsrq: f32,
|
||||||
|
rssi: f32,
|
||||||
|
) {
|
||||||
|
let (parsed_pkt_version, data) = parse_meas_eval(hexlified_bytes);
|
||||||
|
assert_eq!(parsed_pkt_version, pkt_version);
|
||||||
|
assert_eq!(data.get_pci(), pci, "incorrect pci");
|
||||||
|
assert_eq!(data.get_earfcn(), earfcn, "incorrect earfcn");
|
||||||
|
assert_eq!(data.get_meas_rsrp(), rsrp, "incorrect rsrp");
|
||||||
|
assert_eq!(data.get_meas_rsrq(), rsrq, "incorrect rsrq");
|
||||||
|
assert_eq!(data.get_meas_rssi(), rssi, "incorrect rssi");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_scell_meas() {
|
||||||
|
scell_meas_and_eval_case(
|
||||||
|
"040100009C18D60AECC44E00E2244E00FFFCE30FFED80A0047AD56021D310100A2624100",
|
||||||
|
4,
|
||||||
|
214,
|
||||||
|
6300,
|
||||||
|
-101.25,
|
||||||
|
-14.0625,
|
||||||
|
-66.625,
|
||||||
|
);
|
||||||
|
scell_meas_and_eval_case(
|
||||||
|
"05010000160d0000d40e00004bb444005444450039e514133149070048adfe019f310100a23f0000",
|
||||||
|
5,
|
||||||
|
212,
|
||||||
|
3350,
|
||||||
|
-111.3125,
|
||||||
|
-10.4375,
|
||||||
|
-80.875,
|
||||||
|
);
|
||||||
|
scell_meas_and_eval_case(
|
||||||
|
"05010000f424000a4d43434d4e434d41524b45527c307c3236327c317c34323330333233347c7c4d",
|
||||||
|
5,
|
||||||
|
333,
|
||||||
|
167781620,
|
||||||
|
-127.125,
|
||||||
|
-22.25,
|
||||||
|
2.75,
|
||||||
|
);
|
||||||
|
scell_meas_and_eval_case(
|
||||||
|
"0501000000190000a90d0000d9944d00d9944d006081d5d55d2568bc48ad3e027f314fe0891900e0",
|
||||||
|
5,
|
||||||
|
425,
|
||||||
|
6400,
|
||||||
|
-102.4375,
|
||||||
|
-8.0,
|
||||||
|
-77.4375,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ncell_meas_case(
|
||||||
|
hexlified_bytes: &str,
|
||||||
|
pkt_version: u8,
|
||||||
|
earfcn: u32,
|
||||||
|
cells: Vec<(u16, f32, f32, f32)>,
|
||||||
|
) {
|
||||||
|
let (parsed_pkt_version, data) = parse_ncell_measurements(hexlified_bytes);
|
||||||
|
assert_eq!(parsed_pkt_version, pkt_version, "incorrect pkt_version");
|
||||||
|
assert_eq!(data.cells.len(), cells.len(), "incorrect number of cells");
|
||||||
|
assert_eq!(data.get_earfcn(), earfcn, "incorrect earfcn");
|
||||||
|
for (parsed, (pci, rsrp, rssi, rsrq)) in data.cells.iter().zip(cells) {
|
||||||
|
assert_eq!(parsed.pci, pci, "incorrect pci");
|
||||||
|
assert_eq!(parsed.get_meas_rsrp(), rsrp, "incorrect rsrp");
|
||||||
|
assert_eq!(parsed.get_meas_rssi(), rssi, "incorrect rssi");
|
||||||
|
assert_eq!(parsed.get_meas_rsrq(), rsrq, "incorrect rsrq");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ncell_meas() {
|
||||||
|
ncell_meas_case(
|
||||||
|
"040100009C1847008348E44DDEA44C00CAB4CC32B6D8420300000000FF773301FF77330122020100",
|
||||||
|
4,
|
||||||
|
6300,
|
||||||
|
vec![(131, -102.125, -75.75, -17.3125)],
|
||||||
|
);
|
||||||
|
ncell_meas_case(
|
||||||
|
"05010000160d0000480000006cea413bb4433b00b4f3cc33cf3c130200000000ffefc00fffefc00f45081600",
|
||||||
|
5,
|
||||||
|
3350,
|
||||||
|
vec![(108, -120.75, -94.6875, -17.0625)],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,156 +1,12 @@
|
|||||||
//! Diag protocol serialization/deserialization
|
//! Diag LogBody serialization/deserialization
|
||||||
|
|
||||||
use chrono::{DateTime, FixedOffset};
|
use chrono::{DateTime, FixedOffset};
|
||||||
use crc::{Algorithm, Crc};
|
|
||||||
use deku::prelude::*;
|
use deku::prelude::*;
|
||||||
|
|
||||||
use crate::hdlc::{self, hdlc_decapsulate};
|
pub mod ll1;
|
||||||
use log::warn;
|
pub mod mac;
|
||||||
use thiserror::Error;
|
pub mod ml1;
|
||||||
|
pub mod rrc;
|
||||||
pub const MESSAGE_TERMINATOR: u8 = 0x7e;
|
|
||||||
pub const MESSAGE_ESCAPE_CHAR: u8 = 0x7d;
|
|
||||||
|
|
||||||
pub const ESCAPED_MESSAGE_TERMINATOR: u8 = 0x5e;
|
|
||||||
pub const ESCAPED_MESSAGE_ESCAPE_CHAR: u8 = 0x5d;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, DekuWrite)]
|
|
||||||
pub struct RequestContainer {
|
|
||||||
pub data_type: DataType,
|
|
||||||
#[deku(skip)]
|
|
||||||
pub use_mdm: bool,
|
|
||||||
#[deku(skip, cond = "!*use_mdm")]
|
|
||||||
pub mdm_field: i32,
|
|
||||||
pub hdlc_encapsulated_request: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, DekuWrite)]
|
|
||||||
#[deku(id_type = "u32")]
|
|
||||||
pub enum Request {
|
|
||||||
#[deku(id = "115")]
|
|
||||||
LogConfig(LogConfigRequest),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, DekuWrite)]
|
|
||||||
#[deku(id_type = "u32", endian = "little")]
|
|
||||||
pub enum LogConfigRequest {
|
|
||||||
#[deku(id = "1")]
|
|
||||||
RetrieveIdRanges,
|
|
||||||
|
|
||||||
#[deku(id = "3")]
|
|
||||||
SetMask {
|
|
||||||
log_type: u32,
|
|
||||||
log_mask_bitsize: u32,
|
|
||||||
log_mask: Vec<u8>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
|
||||||
#[deku(id_type = "u32", endian = "little")]
|
|
||||||
pub enum DataType {
|
|
||||||
#[deku(id = "32")]
|
|
||||||
UserSpace,
|
|
||||||
#[deku(id_pat = "_")]
|
|
||||||
Other(u32),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Error)]
|
|
||||||
pub enum DiagParsingError {
|
|
||||||
#[error("Failed to parse Message: {0}, data: {1:?}")]
|
|
||||||
MessageParsingError(deku::DekuError, Vec<u8>),
|
|
||||||
#[error("HDLC decapsulation of message failed: {0}, data: {1:?}")]
|
|
||||||
HdlcDecapsulationError(hdlc::HdlcError, Vec<u8>),
|
|
||||||
}
|
|
||||||
|
|
||||||
// this is sorta based on the params qcsuper uses, plus what seems to be used in
|
|
||||||
// https://github.com/fgsect/scat/blob/f1538b397721df3ab8ba12acd26716abcf21f78b/util.py#L47
|
|
||||||
pub const CRC_CCITT_ALG: Algorithm<u16> = Algorithm {
|
|
||||||
poly: 0x1021,
|
|
||||||
init: 0xffff,
|
|
||||||
refin: true,
|
|
||||||
refout: true,
|
|
||||||
width: 16,
|
|
||||||
xorout: 0xffff,
|
|
||||||
check: 0x2189,
|
|
||||||
residue: 0x0000,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub const CRC_CCITT: Crc<u16> = Crc::<u16>::new(&CRC_CCITT_ALG);
|
|
||||||
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
|
||||||
pub struct MessagesContainer {
|
|
||||||
pub data_type: DataType,
|
|
||||||
pub num_messages: u32,
|
|
||||||
#[deku(count = "num_messages")]
|
|
||||||
pub messages: Vec<HdlcEncapsulatedMessage>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MessagesContainer {
|
|
||||||
pub fn into_messages(self) -> Vec<Result<Message, DiagParsingError>> {
|
|
||||||
let mut result = Vec::new();
|
|
||||||
for msg in self.messages {
|
|
||||||
for sub_msg in msg.data.split_inclusive(|&b| b == MESSAGE_TERMINATOR) {
|
|
||||||
match hdlc_decapsulate(sub_msg, &CRC_CCITT) {
|
|
||||||
Ok(data) => match Message::from_bytes((&data, 0)) {
|
|
||||||
Ok(((leftover_bytes, _), res)) => {
|
|
||||||
if !leftover_bytes.is_empty() {
|
|
||||||
warn!(
|
|
||||||
"warning: {} leftover bytes when parsing Message",
|
|
||||||
leftover_bytes.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
result.push(Ok(res));
|
|
||||||
}
|
|
||||||
Err(e) => result.push(Err(DiagParsingError::MessageParsingError(e, data))),
|
|
||||||
},
|
|
||||||
Err(err) => result.push(Err(DiagParsingError::HdlcDecapsulationError(
|
|
||||||
err,
|
|
||||||
sub_msg.to_vec(),
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
|
||||||
pub struct HdlcEncapsulatedMessage {
|
|
||||||
pub len: u32,
|
|
||||||
#[deku(count = "len")]
|
|
||||||
pub data: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
|
||||||
#[deku(id_type = "u8")]
|
|
||||||
pub enum Message {
|
|
||||||
#[deku(id = "16")]
|
|
||||||
Log {
|
|
||||||
pending_msgs: u8,
|
|
||||||
outer_length: u16,
|
|
||||||
inner_length: u16,
|
|
||||||
log_type: u16,
|
|
||||||
timestamp: Timestamp,
|
|
||||||
// pass the log type and log length (inner_length - (sizeof(log_type) + sizeof(timestamp)))
|
|
||||||
#[deku(ctx = "*log_type, inner_length.saturating_sub(12)")]
|
|
||||||
body: LogBody,
|
|
||||||
},
|
|
||||||
|
|
||||||
// kinda unpleasant deku hackery here. deku expects an enum's variant to be
|
|
||||||
// right before its data, but in this case, a status value comes between the
|
|
||||||
// variants and the data. so we need to use deku's context (ctx) feature to
|
|
||||||
// pass those opcodes down to their respective parsers.
|
|
||||||
#[deku(id_pat = "_")]
|
|
||||||
Response {
|
|
||||||
opcode1: u8, // the "id" (from deku's POV) gets parsed into this field
|
|
||||||
opcode2: u8,
|
|
||||||
opcode3: u8,
|
|
||||||
opcode4: u8,
|
|
||||||
subopcode: u32,
|
|
||||||
status: u32,
|
|
||||||
#[deku(ctx = "u32::from_le_bytes([*opcode1, *opcode2, *opcode3, *opcode4]), *subopcode")]
|
|
||||||
payload: ResponsePayload,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||||
#[deku(ctx = "log_type: u16, hdr_len: u16", id = "log_type")]
|
#[deku(ctx = "log_type: u16, hdr_len: u16", id = "log_type")]
|
||||||
@@ -183,7 +39,7 @@ pub enum LogBody {
|
|||||||
LteRrcOtaMessage {
|
LteRrcOtaMessage {
|
||||||
ext_header_version: u8,
|
ext_header_version: u8,
|
||||||
#[deku(ctx = "*ext_header_version")]
|
#[deku(ctx = "*ext_header_version")]
|
||||||
packet: LteRrcOtaPacket,
|
packet: rrc::LteRrcOtaPacket,
|
||||||
},
|
},
|
||||||
// the four NAS command opcodes refer to:
|
// the four NAS command opcodes refer to:
|
||||||
// * 0xb0e2: plain ESM NAS message (incoming)
|
// * 0xb0e2: plain ESM NAS message (incoming)
|
||||||
@@ -222,6 +78,25 @@ pub enum LogBody {
|
|||||||
#[deku(count = "hdr_len")]
|
#[deku(count = "hdr_len")]
|
||||||
msg: Vec<u8>,
|
msg: Vec<u8>,
|
||||||
},
|
},
|
||||||
|
#[deku(id = "0xb17f")]
|
||||||
|
LteMl1ServingCellMeasurementAndEvaluation {
|
||||||
|
data: ml1::serving_cell::MeasurementAndEvaluation,
|
||||||
|
},
|
||||||
|
#[deku(id = "0xb180")]
|
||||||
|
LteMl1NeighborCellsMeasurements {
|
||||||
|
data: ml1::neighbor_cells::Measurements,
|
||||||
|
},
|
||||||
|
#[deku(id = "0xb062")]
|
||||||
|
LteMacRachResponse { packet: mac::Packet },
|
||||||
|
#[deku(id = "0xb063")]
|
||||||
|
LteMacDl { packet: mac::Packet },
|
||||||
|
#[deku(id = "0xb064")]
|
||||||
|
LteMacUl { packet: mac::Packet },
|
||||||
|
#[deku(id = "0xb114")]
|
||||||
|
LteLl1ServingCellTiming {
|
||||||
|
#[deku(ctx = "deku::ctx::Order::Lsb0")]
|
||||||
|
data: ll1::ServingCellTiming,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||||
@@ -237,113 +112,6 @@ pub enum Nas4GMessageDirection {
|
|||||||
Uplink,
|
Uplink,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
|
||||||
#[deku(ctx = "ext_header_version: u8", id = "ext_header_version")]
|
|
||||||
pub enum LteRrcOtaPacket {
|
|
||||||
#[deku(id_pat = "0..=4")]
|
|
||||||
V0 {
|
|
||||||
rrc_rel_maj: u8,
|
|
||||||
rrc_rel_min: u8,
|
|
||||||
bearer_id: u8,
|
|
||||||
phy_cell_id: u16,
|
|
||||||
earfcn: u16,
|
|
||||||
sfn_subfn: u16,
|
|
||||||
pdu_num: u8,
|
|
||||||
len: u16,
|
|
||||||
#[deku(count = "len")]
|
|
||||||
packet: Vec<u8>,
|
|
||||||
},
|
|
||||||
#[deku(id_pat = "5..=7")]
|
|
||||||
V5 {
|
|
||||||
rrc_rel_maj: u8,
|
|
||||||
rrc_rel_min: u8,
|
|
||||||
bearer_id: u8,
|
|
||||||
phy_cell_id: u16,
|
|
||||||
earfcn: u16,
|
|
||||||
sfn_subfn: u16,
|
|
||||||
pdu_num: u8,
|
|
||||||
sib_mask: u32,
|
|
||||||
len: u16,
|
|
||||||
#[deku(count = "len")]
|
|
||||||
packet: Vec<u8>,
|
|
||||||
},
|
|
||||||
#[deku(id_pat = "8..=24")]
|
|
||||||
V8 {
|
|
||||||
rrc_rel_maj: u8,
|
|
||||||
rrc_rel_min: u8,
|
|
||||||
bearer_id: u8,
|
|
||||||
phy_cell_id: u16,
|
|
||||||
earfcn: u32,
|
|
||||||
sfn_subfn: u16,
|
|
||||||
pdu_num: u8,
|
|
||||||
sib_mask: u32,
|
|
||||||
len: u16,
|
|
||||||
#[deku(count = "len")]
|
|
||||||
packet: Vec<u8>,
|
|
||||||
},
|
|
||||||
#[deku(id_pat = "25..")]
|
|
||||||
V25 {
|
|
||||||
rrc_rel_maj: u8,
|
|
||||||
rrc_rel_min: u8,
|
|
||||||
nr_rrc_rel_maj: u8,
|
|
||||||
nr_rrc_rel_min: u8,
|
|
||||||
bearer_id: u8,
|
|
||||||
phy_cell_id: u16,
|
|
||||||
earfcn: u32,
|
|
||||||
sfn_subfn: u16,
|
|
||||||
pdu_num: u8,
|
|
||||||
sib_mask: u32,
|
|
||||||
len: u16,
|
|
||||||
#[deku(count = "len")]
|
|
||||||
packet: Vec<u8>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LteRrcOtaPacket {
|
|
||||||
fn get_sfn_subfn(&self) -> u16 {
|
|
||||||
match self {
|
|
||||||
LteRrcOtaPacket::V0 { sfn_subfn, .. } => *sfn_subfn,
|
|
||||||
LteRrcOtaPacket::V5 { sfn_subfn, .. } => *sfn_subfn,
|
|
||||||
LteRrcOtaPacket::V8 { sfn_subfn, .. } => *sfn_subfn,
|
|
||||||
LteRrcOtaPacket::V25 { sfn_subfn, .. } => *sfn_subfn,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub fn get_sfn(&self) -> u32 {
|
|
||||||
self.get_sfn_subfn() as u32 >> 4
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_subfn(&self) -> u8 {
|
|
||||||
(self.get_sfn_subfn() & 0xf) as u8
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_pdu_num(&self) -> u8 {
|
|
||||||
match self {
|
|
||||||
LteRrcOtaPacket::V0 { pdu_num, .. } => *pdu_num,
|
|
||||||
LteRrcOtaPacket::V5 { pdu_num, .. } => *pdu_num,
|
|
||||||
LteRrcOtaPacket::V8 { pdu_num, .. } => *pdu_num,
|
|
||||||
LteRrcOtaPacket::V25 { pdu_num, .. } => *pdu_num,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_earfcn(&self) -> u32 {
|
|
||||||
match self {
|
|
||||||
LteRrcOtaPacket::V0 { earfcn, .. } => *earfcn as u32,
|
|
||||||
LteRrcOtaPacket::V5 { earfcn, .. } => *earfcn as u32,
|
|
||||||
LteRrcOtaPacket::V8 { earfcn, .. } => *earfcn,
|
|
||||||
LteRrcOtaPacket::V25 { earfcn, .. } => *earfcn,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn take_payload(self) -> Vec<u8> {
|
|
||||||
match self {
|
|
||||||
LteRrcOtaPacket::V0 { packet, .. } => packet,
|
|
||||||
LteRrcOtaPacket::V5 { packet, .. } => packet,
|
|
||||||
LteRrcOtaPacket::V8 { packet, .. } => packet,
|
|
||||||
LteRrcOtaPacket::V25 { packet, .. } => packet,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||||
#[deku(endian = "little")]
|
#[deku(endian = "little")]
|
||||||
pub struct Timestamp {
|
pub struct Timestamp {
|
||||||
@@ -364,119 +132,10 @@ impl Timestamp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
|
||||||
#[deku(ctx = "opcode: u32, subopcode: u32", id = "opcode")]
|
|
||||||
pub enum ResponsePayload {
|
|
||||||
#[deku(id = "115")]
|
|
||||||
LogConfig(#[deku(ctx = "subopcode")] LogConfigResponse),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
|
||||||
#[deku(ctx = "subopcode: u32", id = "subopcode")]
|
|
||||||
pub enum LogConfigResponse {
|
|
||||||
#[deku(id = "1")]
|
|
||||||
RetrieveIdRanges { log_mask_sizes: [u32; 16] },
|
|
||||||
|
|
||||||
#[deku(id = "3")]
|
|
||||||
SetMask,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build_log_mask_request(
|
|
||||||
log_type: u32,
|
|
||||||
log_mask_bitsize: u32,
|
|
||||||
accepted_log_codes: &[u32],
|
|
||||||
) -> Request {
|
|
||||||
let mut current_byte: u8 = 0;
|
|
||||||
let mut num_bits_written: u8 = 0;
|
|
||||||
let mut log_mask: Vec<u8> = vec![];
|
|
||||||
for i in 0..log_mask_bitsize {
|
|
||||||
let log_code: u32 = (log_type << 12) | i;
|
|
||||||
if accepted_log_codes.contains(&log_code) {
|
|
||||||
current_byte |= 1 << num_bits_written;
|
|
||||||
}
|
|
||||||
num_bits_written += 1;
|
|
||||||
|
|
||||||
if num_bits_written == 8 || i == log_mask_bitsize - 1 {
|
|
||||||
log_mask.push(current_byte);
|
|
||||||
current_byte = 0;
|
|
||||||
num_bits_written = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Request::LogConfig(LogConfigRequest::SetMask {
|
|
||||||
log_type,
|
|
||||||
log_mask_bitsize,
|
|
||||||
log_mask,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test {
|
pub(crate) mod test {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::{diag::*, hdlc, log_codes};
|
||||||
// Just about all of these test cases from manually parsing diag packets w/ QCSuper
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_request_serialization() {
|
|
||||||
let req = Request::LogConfig(LogConfigRequest::RetrieveIdRanges);
|
|
||||||
assert_eq!(req.to_bytes().unwrap(), vec![115, 0, 0, 0, 1, 0, 0, 0]);
|
|
||||||
|
|
||||||
let req = Request::LogConfig(LogConfigRequest::SetMask {
|
|
||||||
log_type: 0,
|
|
||||||
log_mask_bitsize: 0,
|
|
||||||
log_mask: vec![],
|
|
||||||
});
|
|
||||||
assert_eq!(
|
|
||||||
req.to_bytes().unwrap(),
|
|
||||||
vec![115, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_build_log_mask_request() {
|
|
||||||
let log_type = 11;
|
|
||||||
let bitsize = 513;
|
|
||||||
let req = build_log_mask_request(
|
|
||||||
log_type,
|
|
||||||
bitsize,
|
|
||||||
&crate::diag_device::LOG_CODES_FOR_RAW_PACKET_LOGGING,
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
req,
|
|
||||||
Request::LogConfig(LogConfigRequest::SetMask {
|
|
||||||
log_type,
|
|
||||||
log_mask_bitsize: bitsize,
|
|
||||||
log_mask: vec![
|
|
||||||
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
|
|
||||||
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x1, 0x0, 0x0, 0x0, 0xc, 0x30, 0x0,
|
|
||||||
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
|
|
||||||
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
|
|
||||||
0x0, 0x0,
|
|
||||||
],
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_request_container() {
|
|
||||||
let req = RequestContainer {
|
|
||||||
data_type: DataType::UserSpace,
|
|
||||||
use_mdm: false,
|
|
||||||
mdm_field: -1,
|
|
||||||
hdlc_encapsulated_request: vec![1, 2, 3, 4],
|
|
||||||
};
|
|
||||||
assert_eq!(req.to_bytes().unwrap(), vec![32, 0, 0, 0, 1, 2, 3, 4,]);
|
|
||||||
let req = RequestContainer {
|
|
||||||
data_type: DataType::UserSpace,
|
|
||||||
use_mdm: true,
|
|
||||||
mdm_field: -1,
|
|
||||||
hdlc_encapsulated_request: vec![1, 2, 3, 4],
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
req.to_bytes().unwrap(),
|
|
||||||
vec![32, 0, 0, 0, 255, 255, 255, 255, 1, 2, 3, 4,]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_logs() {
|
fn test_logs() {
|
||||||
@@ -497,7 +156,7 @@ mod test {
|
|||||||
},
|
},
|
||||||
body: LogBody::LteRrcOtaMessage {
|
body: LogBody::LteRrcOtaMessage {
|
||||||
ext_header_version: 20,
|
ext_header_version: 20,
|
||||||
packet: LteRrcOtaPacket::V8 {
|
packet: rrc::LteRrcOtaPacket::V8 {
|
||||||
rrc_rel_maj: 14,
|
rrc_rel_maj: 14,
|
||||||
rrc_rel_min: 48,
|
rrc_rel_min: 48,
|
||||||
bearer_id: 0,
|
bearer_id: 0,
|
||||||
@@ -514,111 +173,6 @@ mod test {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn make_container(data_type: DataType, message: HdlcEncapsulatedMessage) -> MessagesContainer {
|
|
||||||
MessagesContainer {
|
|
||||||
data_type,
|
|
||||||
num_messages: 1,
|
|
||||||
messages: vec![message],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// this log is based on one captured on a real device -- if it fails to
|
|
||||||
// serialize or deserialize, that's probably a problem with this mock, not
|
|
||||||
// the DiagReader implementation
|
|
||||||
fn get_test_message(payload: &[u8]) -> (HdlcEncapsulatedMessage, Message) {
|
|
||||||
let length_with_payload = 31 + payload.len() as u16;
|
|
||||||
let message = Message::Log {
|
|
||||||
pending_msgs: 0,
|
|
||||||
outer_length: length_with_payload,
|
|
||||||
inner_length: length_with_payload,
|
|
||||||
log_type: 0xb0c0,
|
|
||||||
timestamp: Timestamp {
|
|
||||||
ts: 72659535985485082,
|
|
||||||
},
|
|
||||||
body: LogBody::LteRrcOtaMessage {
|
|
||||||
ext_header_version: 20,
|
|
||||||
packet: LteRrcOtaPacket::V8 {
|
|
||||||
rrc_rel_maj: 14,
|
|
||||||
rrc_rel_min: 48,
|
|
||||||
bearer_id: 0,
|
|
||||||
phy_cell_id: 160,
|
|
||||||
earfcn: 2050,
|
|
||||||
sfn_subfn: 4057,
|
|
||||||
pdu_num: 5,
|
|
||||||
sib_mask: 0,
|
|
||||||
len: payload.len() as u16,
|
|
||||||
packet: payload.to_vec(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
let serialized = message
|
|
||||||
.to_bytes()
|
|
||||||
.expect("failed to serialize test message");
|
|
||||||
let encapsulated_data = hdlc::hdlc_encapsulate(&serialized, &CRC_CCITT);
|
|
||||||
let encapsulated = HdlcEncapsulatedMessage {
|
|
||||||
len: encapsulated_data.len() as u32,
|
|
||||||
data: encapsulated_data,
|
|
||||||
};
|
|
||||||
(encapsulated, message)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_containers_with_multiple_messages() {
|
|
||||||
let (encapsulated1, message1) = get_test_message(&[1]);
|
|
||||||
let (encapsulated2, message2) = get_test_message(&[2]);
|
|
||||||
let mut container = make_container(DataType::UserSpace, encapsulated1);
|
|
||||||
container.messages.push(encapsulated2);
|
|
||||||
container.num_messages += 1;
|
|
||||||
assert_eq!(container.into_messages(), vec![Ok(message1), Ok(message2)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_containers_with_concatenated_message() {
|
|
||||||
let (mut encapsulated1, message1) = get_test_message(&[1]);
|
|
||||||
let (encapsulated2, message2) = get_test_message(&[2]);
|
|
||||||
encapsulated1.data.extend(encapsulated2.data);
|
|
||||||
encapsulated1.len += encapsulated2.len;
|
|
||||||
let container = make_container(DataType::UserSpace, encapsulated1);
|
|
||||||
assert_eq!(container.into_messages(), vec![Ok(message1), Ok(message2)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_handles_parsing_errors() {
|
|
||||||
let (encapsulated1, message1) = get_test_message(&[1]);
|
|
||||||
let bad_message = hdlc::hdlc_encapsulate(&[0x01, 0x02, 0x03, 0x04], &CRC_CCITT);
|
|
||||||
let encapsulated2 = HdlcEncapsulatedMessage {
|
|
||||||
len: bad_message.len() as u32,
|
|
||||||
data: bad_message,
|
|
||||||
};
|
|
||||||
let mut container = make_container(DataType::UserSpace, encapsulated1);
|
|
||||||
container.messages.push(encapsulated2);
|
|
||||||
container.num_messages += 1;
|
|
||||||
let result = container.into_messages();
|
|
||||||
assert_eq!(result[0], Ok(message1));
|
|
||||||
assert!(matches!(
|
|
||||||
result[1],
|
|
||||||
Err(DiagParsingError::MessageParsingError(_, _))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_handles_encapsulation_errors() {
|
|
||||||
let (encapsulated1, message1) = get_test_message(&[1]);
|
|
||||||
let bad_encapsulation = HdlcEncapsulatedMessage {
|
|
||||||
len: 4,
|
|
||||||
data: vec![0x01, 0x02, 0x03, 0x04],
|
|
||||||
};
|
|
||||||
let mut container = make_container(DataType::UserSpace, encapsulated1);
|
|
||||||
container.messages.push(bad_encapsulation);
|
|
||||||
container.num_messages += 1;
|
|
||||||
let result = container.into_messages();
|
|
||||||
assert_eq!(result[0], Ok(message1));
|
|
||||||
assert!(matches!(
|
|
||||||
result[1],
|
|
||||||
Err(DiagParsingError::HdlcDecapsulationError(_, _))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_fuzz_crash_inner_length_underflow() {
|
fn test_fuzz_crash_inner_length_underflow() {
|
||||||
// Regression test: inner_length < 12 previously caused panic.
|
// Regression test: inner_length < 12 previously caused panic.
|
||||||
@@ -663,6 +217,184 @@ mod test {
|
|||||||
let _ = Message::from_bytes((ip_msg, 0));
|
let _ = Message::from_bytes((ip_msg, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Just about all of these test cases from manually parsing diag packets w/ QCSuper
|
||||||
|
#[test]
|
||||||
|
fn test_request_serialization() {
|
||||||
|
let req = Request::LogConfig(LogConfigRequest::RetrieveIdRanges);
|
||||||
|
assert_eq!(req.to_bytes().unwrap(), vec![115, 0, 0, 0, 1, 0, 0, 0]);
|
||||||
|
|
||||||
|
let req = Request::LogConfig(LogConfigRequest::SetMask {
|
||||||
|
log_type: 0,
|
||||||
|
log_mask_bitsize: 0,
|
||||||
|
log_mask: vec![],
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
req.to_bytes().unwrap(),
|
||||||
|
vec![115, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_log_mask_request() {
|
||||||
|
let log_type = 11;
|
||||||
|
let bitsize = 513;
|
||||||
|
let req = build_log_mask_request(
|
||||||
|
log_type,
|
||||||
|
bitsize,
|
||||||
|
&[
|
||||||
|
log_codes::LOG_GSM_RR_SIGNALING_MESSAGE_C,
|
||||||
|
log_codes::WCDMA_SIGNALLING_MESSAGE,
|
||||||
|
log_codes::LOG_LTE_RRC_OTA_MSG_LOG_C,
|
||||||
|
log_codes::LOG_NR_RRC_OTA_MSG_LOG_C,
|
||||||
|
log_codes::LOG_UMTS_NAS_OTA_MESSAGE_LOG_PACKET_C,
|
||||||
|
log_codes::LOG_LTE_NAS_ESM_OTA_IN_MSG_LOG_C,
|
||||||
|
log_codes::LOG_LTE_NAS_ESM_OTA_OUT_MSG_LOG_C,
|
||||||
|
log_codes::LOG_LTE_NAS_EMM_OTA_IN_MSG_LOG_C,
|
||||||
|
log_codes::LOG_LTE_NAS_EMM_OTA_OUT_MSG_LOG_C,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
req,
|
||||||
|
Request::LogConfig(LogConfigRequest::SetMask {
|
||||||
|
log_type,
|
||||||
|
log_mask_bitsize: bitsize,
|
||||||
|
log_mask: vec![
|
||||||
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0,
|
||||||
|
0, 0, 12, 48, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_request_container() {
|
||||||
|
let req = RequestContainer {
|
||||||
|
data_type: DataType::UserSpace,
|
||||||
|
use_mdm: false,
|
||||||
|
mdm_field: -1,
|
||||||
|
hdlc_encapsulated_request: vec![1, 2, 3, 4],
|
||||||
|
};
|
||||||
|
assert_eq!(req.to_bytes().unwrap(), vec![32, 0, 0, 0, 1, 2, 3, 4,]);
|
||||||
|
let req = RequestContainer {
|
||||||
|
data_type: DataType::UserSpace,
|
||||||
|
use_mdm: true,
|
||||||
|
mdm_field: -1,
|
||||||
|
hdlc_encapsulated_request: vec![1, 2, 3, 4],
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
req.to_bytes().unwrap(),
|
||||||
|
vec![32, 0, 0, 0, 255, 255, 255, 255, 1, 2, 3, 4,]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_container(data_type: DataType, message: HdlcEncapsulatedMessage) -> MessagesContainer {
|
||||||
|
MessagesContainer {
|
||||||
|
data_type,
|
||||||
|
num_messages: 1,
|
||||||
|
messages: vec![message],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// this log is based on one captured on a real device -- if it fails to
|
||||||
|
// serialize or deserialize, that's probably a problem with this mock, not
|
||||||
|
// the DiagReader implementation
|
||||||
|
pub fn get_test_message(payload: &[u8]) -> (HdlcEncapsulatedMessage, Message) {
|
||||||
|
let length_with_payload = 31 + payload.len() as u16;
|
||||||
|
let message = Message::Log {
|
||||||
|
pending_msgs: 0,
|
||||||
|
outer_length: length_with_payload,
|
||||||
|
inner_length: length_with_payload,
|
||||||
|
log_type: 0xb0c0,
|
||||||
|
timestamp: Timestamp {
|
||||||
|
ts: 72659535985485082,
|
||||||
|
},
|
||||||
|
body: LogBody::LteRrcOtaMessage {
|
||||||
|
ext_header_version: 20,
|
||||||
|
packet: diaglog::rrc::LteRrcOtaPacket::V8 {
|
||||||
|
rrc_rel_maj: 14,
|
||||||
|
rrc_rel_min: 48,
|
||||||
|
bearer_id: 0,
|
||||||
|
phy_cell_id: 160,
|
||||||
|
earfcn: 2050,
|
||||||
|
sfn_subfn: 4057,
|
||||||
|
pdu_num: 5,
|
||||||
|
sib_mask: 0,
|
||||||
|
len: payload.len() as u16,
|
||||||
|
packet: payload.to_vec(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let serialized = message
|
||||||
|
.to_bytes()
|
||||||
|
.expect("failed to serialize test message");
|
||||||
|
let encapsulated_data = hdlc::hdlc_encapsulate(&serialized, &CRC_CCITT);
|
||||||
|
let encapsulated = HdlcEncapsulatedMessage {
|
||||||
|
len: encapsulated_data.len() as u32,
|
||||||
|
data: encapsulated_data,
|
||||||
|
};
|
||||||
|
// sanity check
|
||||||
|
assert_eq!(&Message::from_hdlc(&encapsulated.data).unwrap(), &message);
|
||||||
|
(encapsulated, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_containers_with_multiple_messages() {
|
||||||
|
let (encapsulated1, message1) = get_test_message(&[1]);
|
||||||
|
let (encapsulated2, message2) = get_test_message(&[2]);
|
||||||
|
let mut container = make_container(DataType::UserSpace, encapsulated1);
|
||||||
|
container.messages.push(encapsulated2);
|
||||||
|
container.num_messages += 1;
|
||||||
|
assert_eq!(container.messages(), vec![Ok(message1), Ok(message2)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_containers_with_concatenated_message() {
|
||||||
|
let (mut encapsulated1, message1) = get_test_message(&[1]);
|
||||||
|
let (encapsulated2, message2) = get_test_message(&[2]);
|
||||||
|
encapsulated1.data.extend(encapsulated2.data);
|
||||||
|
encapsulated1.len += encapsulated2.len;
|
||||||
|
let container = make_container(DataType::UserSpace, encapsulated1);
|
||||||
|
assert_eq!(container.messages(), vec![Ok(message1), Ok(message2)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_handles_parsing_errors() {
|
||||||
|
let (encapsulated1, message1) = get_test_message(&[1]);
|
||||||
|
let bad_message = hdlc::hdlc_encapsulate(&[0x01, 0x02, 0x03, 0x04], &CRC_CCITT);
|
||||||
|
let encapsulated2 = HdlcEncapsulatedMessage {
|
||||||
|
len: bad_message.len() as u32,
|
||||||
|
data: bad_message,
|
||||||
|
};
|
||||||
|
let mut container = make_container(DataType::UserSpace, encapsulated1);
|
||||||
|
container.messages.push(encapsulated2);
|
||||||
|
container.num_messages += 1;
|
||||||
|
let result = container.messages();
|
||||||
|
assert_eq!(result[0], Ok(message1));
|
||||||
|
assert!(matches!(
|
||||||
|
result[1],
|
||||||
|
Err(DiagParsingError::MessageParsingError(_, _))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_handles_encapsulation_errors() {
|
||||||
|
let (encapsulated1, message1) = get_test_message(&[1]);
|
||||||
|
let bad_encapsulation = HdlcEncapsulatedMessage {
|
||||||
|
len: 4,
|
||||||
|
data: vec![0x01, 0x02, 0x03, 0x04],
|
||||||
|
};
|
||||||
|
let mut container = make_container(DataType::UserSpace, encapsulated1);
|
||||||
|
container.messages.push(bad_encapsulation);
|
||||||
|
container.num_messages += 1;
|
||||||
|
let result = container.messages();
|
||||||
|
assert_eq!(result[0], Ok(message1));
|
||||||
|
assert!(matches!(
|
||||||
|
result[1],
|
||||||
|
Err(DiagParsingError::HdlcDecapsulationError(_, _))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_fuzz_crash_response_opcode_parsing() {
|
fn test_fuzz_crash_response_opcode_parsing() {
|
||||||
// Regression test: Upgrading to deku 0.20 caused incorrect parsing of Response messages.
|
// Regression test: Upgrading to deku 0.20 caused incorrect parsing of Response messages.
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
//! Diag LTE RRC serialization/deserialization
|
||||||
|
|
||||||
|
use deku::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||||
|
#[deku(ctx = "ext_header_version: u8", id = "ext_header_version")]
|
||||||
|
pub enum LteRrcOtaPacket {
|
||||||
|
#[deku(id_pat = "0..=4")]
|
||||||
|
V0 {
|
||||||
|
rrc_rel_maj: u8,
|
||||||
|
rrc_rel_min: u8,
|
||||||
|
bearer_id: u8,
|
||||||
|
phy_cell_id: u16,
|
||||||
|
earfcn: u16,
|
||||||
|
sfn_subfn: u16,
|
||||||
|
pdu_num: u8,
|
||||||
|
len: u16,
|
||||||
|
#[deku(count = "len")]
|
||||||
|
packet: Vec<u8>,
|
||||||
|
},
|
||||||
|
#[deku(id_pat = "5..=7")]
|
||||||
|
V5 {
|
||||||
|
rrc_rel_maj: u8,
|
||||||
|
rrc_rel_min: u8,
|
||||||
|
bearer_id: u8,
|
||||||
|
phy_cell_id: u16,
|
||||||
|
earfcn: u16,
|
||||||
|
sfn_subfn: u16,
|
||||||
|
pdu_num: u8,
|
||||||
|
sib_mask: u32,
|
||||||
|
len: u16,
|
||||||
|
#[deku(count = "len")]
|
||||||
|
packet: Vec<u8>,
|
||||||
|
},
|
||||||
|
#[deku(id_pat = "8..=24")]
|
||||||
|
V8 {
|
||||||
|
rrc_rel_maj: u8,
|
||||||
|
rrc_rel_min: u8,
|
||||||
|
bearer_id: u8,
|
||||||
|
phy_cell_id: u16,
|
||||||
|
earfcn: u32,
|
||||||
|
sfn_subfn: u16,
|
||||||
|
pdu_num: u8,
|
||||||
|
sib_mask: u32,
|
||||||
|
len: u16,
|
||||||
|
#[deku(count = "len")]
|
||||||
|
packet: Vec<u8>,
|
||||||
|
},
|
||||||
|
#[deku(id_pat = "25..")]
|
||||||
|
V25 {
|
||||||
|
rrc_rel_maj: u8,
|
||||||
|
rrc_rel_min: u8,
|
||||||
|
nr_rrc_rel_maj: u8,
|
||||||
|
nr_rrc_rel_min: u8,
|
||||||
|
bearer_id: u8,
|
||||||
|
phy_cell_id: u16,
|
||||||
|
earfcn: u32,
|
||||||
|
sfn_subfn: u16,
|
||||||
|
pdu_num: u8,
|
||||||
|
sib_mask: u32,
|
||||||
|
len: u16,
|
||||||
|
#[deku(count = "len")]
|
||||||
|
packet: Vec<u8>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LteRrcOtaPacket {
|
||||||
|
fn get_sfn_subfn(&self) -> u16 {
|
||||||
|
match self {
|
||||||
|
LteRrcOtaPacket::V0 { sfn_subfn, .. } => *sfn_subfn,
|
||||||
|
LteRrcOtaPacket::V5 { sfn_subfn, .. } => *sfn_subfn,
|
||||||
|
LteRrcOtaPacket::V8 { sfn_subfn, .. } => *sfn_subfn,
|
||||||
|
LteRrcOtaPacket::V25 { sfn_subfn, .. } => *sfn_subfn,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn get_sfn(&self) -> u32 {
|
||||||
|
self.get_sfn_subfn() as u32 >> 4
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_subfn(&self) -> u8 {
|
||||||
|
(self.get_sfn_subfn() & 0xf) as u8
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_pdu_num(&self) -> u8 {
|
||||||
|
match self {
|
||||||
|
LteRrcOtaPacket::V0 { pdu_num, .. } => *pdu_num,
|
||||||
|
LteRrcOtaPacket::V5 { pdu_num, .. } => *pdu_num,
|
||||||
|
LteRrcOtaPacket::V8 { pdu_num, .. } => *pdu_num,
|
||||||
|
LteRrcOtaPacket::V25 { pdu_num, .. } => *pdu_num,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_earfcn(&self) -> u32 {
|
||||||
|
match self {
|
||||||
|
LteRrcOtaPacket::V0 { earfcn, .. } => *earfcn as u32,
|
||||||
|
LteRrcOtaPacket::V5 { earfcn, .. } => *earfcn as u32,
|
||||||
|
LteRrcOtaPacket::V8 { earfcn, .. } => *earfcn,
|
||||||
|
LteRrcOtaPacket::V25 { earfcn, .. } => *earfcn,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn take_payload(self) -> Vec<u8> {
|
||||||
|
match self {
|
||||||
|
LteRrcOtaPacket::V0 { packet, .. } => packet,
|
||||||
|
LteRrcOtaPacket::V5 { packet, .. } => packet,
|
||||||
|
LteRrcOtaPacket::V8 { packet, .. } => packet,
|
||||||
|
LteRrcOtaPacket::V25 { packet, .. } => packet,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
//! Diag protocol serialization/deserialization
|
||||||
|
|
||||||
|
use crc::{Algorithm, Crc};
|
||||||
|
use deku::prelude::*;
|
||||||
|
|
||||||
|
use crate::hdlc::{self, hdlc_decapsulate};
|
||||||
|
use log::warn;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
pub mod diaglog;
|
||||||
|
|
||||||
|
use diaglog::{LogBody, Timestamp};
|
||||||
|
|
||||||
|
pub const MESSAGE_TERMINATOR: u8 = 0x7e;
|
||||||
|
pub const MESSAGE_ESCAPE_CHAR: u8 = 0x7d;
|
||||||
|
|
||||||
|
pub const ESCAPED_MESSAGE_TERMINATOR: u8 = 0x5e;
|
||||||
|
pub const ESCAPED_MESSAGE_ESCAPE_CHAR: u8 = 0x5d;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, DekuWrite)]
|
||||||
|
pub struct RequestContainer {
|
||||||
|
pub data_type: DataType,
|
||||||
|
#[deku(skip)]
|
||||||
|
pub use_mdm: bool,
|
||||||
|
#[deku(skip, cond = "!*use_mdm")]
|
||||||
|
pub mdm_field: i32,
|
||||||
|
pub hdlc_encapsulated_request: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, DekuWrite)]
|
||||||
|
#[deku(id_type = "u32")]
|
||||||
|
pub enum Request {
|
||||||
|
#[deku(id = "115")]
|
||||||
|
LogConfig(LogConfigRequest),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, DekuWrite)]
|
||||||
|
#[deku(id_type = "u32", endian = "little")]
|
||||||
|
pub enum LogConfigRequest {
|
||||||
|
#[deku(id = "1")]
|
||||||
|
RetrieveIdRanges,
|
||||||
|
|
||||||
|
#[deku(id = "3")]
|
||||||
|
SetMask {
|
||||||
|
log_type: u32,
|
||||||
|
log_mask_bitsize: u32,
|
||||||
|
log_mask: Vec<u8>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||||
|
#[deku(id_type = "u32", endian = "little")]
|
||||||
|
pub enum DataType {
|
||||||
|
#[deku(id = "32")]
|
||||||
|
UserSpace,
|
||||||
|
#[deku(id_pat = "_")]
|
||||||
|
Other(u32),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Error)]
|
||||||
|
pub enum DiagParsingError {
|
||||||
|
#[error("Failed to parse Message: {0}, data: {1:?}")]
|
||||||
|
MessageParsingError(deku::DekuError, Vec<u8>),
|
||||||
|
#[error("HDLC decapsulation of message failed: {0}, data: {1:?}")]
|
||||||
|
HdlcDecapsulationError(hdlc::HdlcError, Vec<u8>),
|
||||||
|
}
|
||||||
|
|
||||||
|
// this is sorta based on the params qcsuper uses, plus what seems to be used in
|
||||||
|
// https://github.com/fgsect/scat/blob/f1538b397721df3ab8ba12acd26716abcf21f78b/util.py#L47
|
||||||
|
pub const CRC_CCITT_ALG: Algorithm<u16> = Algorithm {
|
||||||
|
poly: 0x1021,
|
||||||
|
init: 0xffff,
|
||||||
|
refin: true,
|
||||||
|
refout: true,
|
||||||
|
width: 16,
|
||||||
|
xorout: 0xffff,
|
||||||
|
check: 0x2189,
|
||||||
|
residue: 0x0000,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const CRC_CCITT: Crc<u16> = Crc::<u16>::new(&CRC_CCITT_ALG);
|
||||||
|
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||||
|
pub struct MessagesContainer {
|
||||||
|
pub data_type: DataType,
|
||||||
|
pub num_messages: u32,
|
||||||
|
#[deku(count = "num_messages")]
|
||||||
|
pub messages: Vec<HdlcEncapsulatedMessage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MessagesContainer {
|
||||||
|
pub fn messages(&self) -> Vec<Result<Message, DiagParsingError>> {
|
||||||
|
let mut result = Vec::new();
|
||||||
|
for msg in &self.messages {
|
||||||
|
for sub_msg in msg.data.split_inclusive(|&b| b == MESSAGE_TERMINATOR) {
|
||||||
|
result.push(Message::from_hdlc(sub_msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||||
|
pub struct HdlcEncapsulatedMessage {
|
||||||
|
pub len: u32,
|
||||||
|
#[deku(count = "len")]
|
||||||
|
pub data: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||||
|
#[deku(id_type = "u8")]
|
||||||
|
pub enum Message {
|
||||||
|
#[deku(id = "16")]
|
||||||
|
Log {
|
||||||
|
pending_msgs: u8,
|
||||||
|
outer_length: u16,
|
||||||
|
inner_length: u16,
|
||||||
|
log_type: u16,
|
||||||
|
timestamp: Timestamp,
|
||||||
|
// pass the log type and log length (inner_length - (sizeof(log_type) + sizeof(timestamp)))
|
||||||
|
#[deku(ctx = "*log_type, inner_length.saturating_sub(12)")]
|
||||||
|
body: LogBody,
|
||||||
|
},
|
||||||
|
|
||||||
|
// kinda unpleasant deku hackery here. deku expects an enum's variant to be
|
||||||
|
// right before its data, but in this case, a status value comes between the
|
||||||
|
// variants and the data. so we need to use deku's context (ctx) feature to
|
||||||
|
// pass those opcodes down to their respective parsers.
|
||||||
|
#[deku(id_pat = "_")]
|
||||||
|
Response {
|
||||||
|
opcode1: u8, // the "id" (from deku's POV) gets parsed into this field
|
||||||
|
opcode2: u8,
|
||||||
|
opcode3: u8,
|
||||||
|
opcode4: u8,
|
||||||
|
subopcode: u32,
|
||||||
|
status: u32,
|
||||||
|
#[deku(ctx = "u32::from_le_bytes([*opcode1, *opcode2, *opcode3, *opcode4]), *subopcode")]
|
||||||
|
payload: ResponsePayload,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Message {
|
||||||
|
pub fn from_hdlc(data: &[u8]) -> Result<Message, DiagParsingError> {
|
||||||
|
match hdlc_decapsulate(data, &CRC_CCITT) {
|
||||||
|
Ok(data) => match Message::from_bytes((&data, 0)) {
|
||||||
|
Ok(((leftover_bytes, _), res)) => {
|
||||||
|
if !leftover_bytes.is_empty() {
|
||||||
|
warn!(
|
||||||
|
"warning: {} leftover bytes when parsing Message",
|
||||||
|
leftover_bytes.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(res)
|
||||||
|
}
|
||||||
|
Err(e) => Err(DiagParsingError::MessageParsingError(e, data)),
|
||||||
|
},
|
||||||
|
Err(err) => Err(DiagParsingError::HdlcDecapsulationError(err, data.to_vec())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether this message should be parsed into a GSMTAP packet for
|
||||||
|
/// display in pcap files
|
||||||
|
pub fn is_gsmtap_message(&self) -> bool {
|
||||||
|
let Message::Log { body, .. } = self else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
match body {
|
||||||
|
LogBody::LteRrcOtaMessage { .. } => true,
|
||||||
|
LogBody::LteMacRachResponse { .. } => true,
|
||||||
|
LogBody::LteMl1NeighborCellsMeasurements { .. } => true,
|
||||||
|
LogBody::Nas4GMessage { .. } => true,
|
||||||
|
_ => false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||||
|
#[deku(ctx = "opcode: u32, subopcode: u32", id = "opcode")]
|
||||||
|
pub enum ResponsePayload {
|
||||||
|
#[deku(id = "115")]
|
||||||
|
LogConfig(#[deku(ctx = "subopcode")] LogConfigResponse),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||||
|
#[deku(ctx = "subopcode: u32", id = "subopcode")]
|
||||||
|
pub enum LogConfigResponse {
|
||||||
|
#[deku(id = "1")]
|
||||||
|
RetrieveIdRanges { log_mask_sizes: [u32; 16] },
|
||||||
|
|
||||||
|
#[deku(id = "3")]
|
||||||
|
SetMask,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_log_mask_request(
|
||||||
|
log_type: u32,
|
||||||
|
log_mask_bitsize: u32,
|
||||||
|
accepted_log_codes: &[u32],
|
||||||
|
) -> Request {
|
||||||
|
let mut current_byte: u8 = 0;
|
||||||
|
let mut num_bits_written: u8 = 0;
|
||||||
|
let mut log_mask: Vec<u8> = vec![];
|
||||||
|
for i in 0..log_mask_bitsize {
|
||||||
|
let log_code: u32 = (log_type << 12) | i;
|
||||||
|
if accepted_log_codes.contains(&log_code) {
|
||||||
|
current_byte |= 1 << num_bits_written;
|
||||||
|
}
|
||||||
|
num_bits_written += 1;
|
||||||
|
|
||||||
|
if num_bits_written == 8 || i == log_mask_bitsize - 1 {
|
||||||
|
log_mask.push(current_byte);
|
||||||
|
current_byte = 0;
|
||||||
|
num_bits_written = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Request::LogConfig(LogConfigRequest::SetMask {
|
||||||
|
log_type,
|
||||||
|
log_mask_bitsize,
|
||||||
|
log_mask,
|
||||||
|
})
|
||||||
|
}
|
||||||
+21
-13
@@ -40,22 +40,30 @@ pub enum DiagDeviceError {
|
|||||||
ParseMessagesContainerError(deku::DekuError),
|
ParseMessagesContainerError(deku::DekuError),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const LOG_CODES_FOR_RAW_PACKET_LOGGING: [u32; 11] = [
|
pub const LOG_CODES_FOR_RAW_PACKET_LOGGING: [u32; 17] = [
|
||||||
// Layer 2:
|
// Layer 2:
|
||||||
log_codes::LOG_GPRS_MAC_SIGNALLING_MESSAGE_C, // 0x5226
|
log_codes::LOG_GPRS_MAC_SIGNALLING_MESSAGE_C,
|
||||||
// Layer 3:
|
// Layer 3:
|
||||||
log_codes::LOG_GSM_RR_SIGNALING_MESSAGE_C, // 0x512f
|
log_codes::LOG_GSM_RR_SIGNALING_MESSAGE_C,
|
||||||
log_codes::WCDMA_SIGNALLING_MESSAGE, // 0x412f
|
log_codes::WCDMA_SIGNALLING_MESSAGE,
|
||||||
log_codes::LOG_LTE_RRC_OTA_MSG_LOG_C, // 0xb0c0
|
log_codes::LOG_LTE_RRC_OTA_MSG_LOG_C,
|
||||||
log_codes::LOG_NR_RRC_OTA_MSG_LOG_C, // 0xb821
|
log_codes::LOG_NR_RRC_OTA_MSG_LOG_C,
|
||||||
// NAS:
|
// NAS:
|
||||||
log_codes::LOG_UMTS_NAS_OTA_MESSAGE_LOG_PACKET_C, // 0x713a
|
log_codes::LOG_UMTS_NAS_OTA_MESSAGE_LOG_PACKET_C,
|
||||||
log_codes::LOG_LTE_NAS_ESM_OTA_IN_MSG_LOG_C, // 0xb0e2
|
log_codes::LOG_LTE_NAS_ESM_OTA_IN_MSG_LOG_C,
|
||||||
log_codes::LOG_LTE_NAS_ESM_OTA_OUT_MSG_LOG_C, // 0xb0e3
|
log_codes::LOG_LTE_NAS_ESM_OTA_OUT_MSG_LOG_C,
|
||||||
log_codes::LOG_LTE_NAS_EMM_OTA_IN_MSG_LOG_C, // 0xb0ec
|
log_codes::LOG_LTE_NAS_EMM_OTA_IN_MSG_LOG_C,
|
||||||
log_codes::LOG_LTE_NAS_EMM_OTA_OUT_MSG_LOG_C, // 0xb0ed
|
log_codes::LOG_LTE_NAS_EMM_OTA_OUT_MSG_LOG_C,
|
||||||
// User IP traffic:
|
// User IP traffic:
|
||||||
log_codes::LOG_DATA_PROTOCOL_LOGGING_C, // 0x11eb
|
log_codes::LOG_DATA_PROTOCOL_LOGGING_C,
|
||||||
|
// LTE physical layer serving cell measurements: RSRP, RSRQ, RSSI
|
||||||
|
log_codes::LOG_LTE_ML1_SERVING_CELL_MEAS_AND_EVAL_C,
|
||||||
|
log_codes::LOG_LTE_ML1_NEIGHBOR_MEAS,
|
||||||
|
// LTE MAC Random Access Channel response: contains Timing Advance
|
||||||
|
log_codes::LOG_LTE_MAC_RACH_RESPONSE_C,
|
||||||
|
log_codes::LOG_LTE_MAC_DL,
|
||||||
|
log_codes::LOG_LTE_MAC_UL,
|
||||||
|
0xb114, // maybe timing advance related?
|
||||||
];
|
];
|
||||||
|
|
||||||
const BUFFER_LEN: usize = 1024 * 1024 * 10;
|
const BUFFER_LEN: usize = 1024 * 1024 * 10;
|
||||||
@@ -212,7 +220,7 @@ impl DiagDevice {
|
|||||||
if container.data_type != DataType::UserSpace {
|
if container.data_type != DataType::UserSpace {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
return Ok(container.into_messages());
|
return Ok(container.messages());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
//! The structs/enum values defined here are derived from a number of sources:
|
||||||
|
//! * SCAT's construction of MAC GSMTAP packets: https://github.com/fgsect/scat/blob/9763cb5b1dcd5ee980f5b0ead9a8d520c8c51a51/src/scat/parsers/qualcomm/diagltelogparser.py#L562-L640
|
||||||
|
//! * https://www.sharetechnote.com/html/MAC_LTE.html#MAC_PDU_Structure_RAR
|
||||||
|
//! * 3GPP's TS 36.321, mostly sections 6.1.4, 6.1.5, and 6.1.6
|
||||||
|
|
||||||
|
use deku::prelude::*;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
diag::diaglog::mac::SubpacketBody,
|
||||||
|
gsmtap::{GsmtapHeader, GsmtapMessage, GsmtapType},
|
||||||
|
};
|
||||||
|
use deku::{DekuContainerWrite, DekuError};
|
||||||
|
|
||||||
|
#[derive(DekuRead, DekuWrite)]
|
||||||
|
pub struct Header {
|
||||||
|
pub radio_type: RadioType,
|
||||||
|
pub direction: Direction,
|
||||||
|
pub rnti_type: RntiType,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DekuRead, DekuWrite)]
|
||||||
|
#[deku(id_type = "u8")]
|
||||||
|
pub enum RadioType {
|
||||||
|
#[deku(id = "1")]
|
||||||
|
Fdd,
|
||||||
|
#[deku(id = "2")]
|
||||||
|
Tdd,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DekuRead, DekuWrite)]
|
||||||
|
#[deku(id_type = "u8")]
|
||||||
|
pub enum Direction {
|
||||||
|
#[deku(id = "0")]
|
||||||
|
Uplink,
|
||||||
|
#[deku(id = "1")]
|
||||||
|
Downlink,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DekuRead, DekuWrite)]
|
||||||
|
#[deku(id_type = "u8")]
|
||||||
|
pub enum RntiType {
|
||||||
|
#[deku(id = "0")]
|
||||||
|
No,
|
||||||
|
#[deku(id = "1")]
|
||||||
|
P,
|
||||||
|
#[deku(id = "2")]
|
||||||
|
Ra,
|
||||||
|
#[deku(id = "3")]
|
||||||
|
C,
|
||||||
|
#[deku(id = "4")]
|
||||||
|
Ri,
|
||||||
|
#[deku(id = "5")]
|
||||||
|
Sps,
|
||||||
|
#[deku(id = "6")]
|
||||||
|
M,
|
||||||
|
#[deku(id = "7")]
|
||||||
|
Sl,
|
||||||
|
#[deku(id = "9")]
|
||||||
|
Sc,
|
||||||
|
#[deku(id = "10")]
|
||||||
|
G,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DekuRead, DekuWrite)]
|
||||||
|
#[deku(endian = "big")]
|
||||||
|
pub struct ETRAPIDSubheader {
|
||||||
|
#[deku(bits = 1)]
|
||||||
|
pub extended: bool,
|
||||||
|
#[deku(bits = 1)]
|
||||||
|
pub type_field: bool,
|
||||||
|
#[deku(bits = 6)]
|
||||||
|
pub rapid: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DekuRead, DekuWrite)]
|
||||||
|
#[deku(endian = "big")]
|
||||||
|
pub struct RACHResponse {
|
||||||
|
#[deku(pad_bits_before = "1", bits = 11)]
|
||||||
|
pub tac: u16,
|
||||||
|
#[deku(bits = 20)]
|
||||||
|
pub ul_grant: u32,
|
||||||
|
pub tc_rnti: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mac_subpacket_to_gsmtap(
|
||||||
|
subpacket: &SubpacketBody,
|
||||||
|
) -> Result<Option<GsmtapMessage>, DekuError> {
|
||||||
|
match subpacket {
|
||||||
|
SubpacketBody::RachAttempt(attempt) => {
|
||||||
|
let (Some(msg1), Some(msg2), Some(msg3)) =
|
||||||
|
(attempt.get_msg1(), attempt.get_msg2(), attempt.get_msg3())
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let mut payload = Vec::new();
|
||||||
|
payload.extend(
|
||||||
|
Header {
|
||||||
|
radio_type: RadioType::Fdd,
|
||||||
|
direction: Direction::Downlink,
|
||||||
|
rnti_type: RntiType::Ra,
|
||||||
|
}
|
||||||
|
.to_bytes()?,
|
||||||
|
);
|
||||||
|
payload.push(0x01); // MAC Payload Tag
|
||||||
|
payload.extend(
|
||||||
|
ETRAPIDSubheader {
|
||||||
|
extended: false,
|
||||||
|
type_field: true,
|
||||||
|
rapid: msg1.get_preamble_index(),
|
||||||
|
}
|
||||||
|
.to_bytes()?,
|
||||||
|
);
|
||||||
|
payload.extend(
|
||||||
|
RACHResponse {
|
||||||
|
tac: msg2.ta,
|
||||||
|
ul_grant: msg3.get_grant(),
|
||||||
|
tc_rnti: msg2.tc_rnti,
|
||||||
|
}
|
||||||
|
.to_bytes()?,
|
||||||
|
);
|
||||||
|
Ok(Some(GsmtapMessage {
|
||||||
|
header: GsmtapHeader::new(GsmtapType::LteMacFramed),
|
||||||
|
payload,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
_ => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::diag::diaglog::mac::Packet;
|
||||||
|
use crate::diag::diaglog::mac::test::mac_rach_test_packets_from_scat;
|
||||||
|
use crate::test_util::unhexlify;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn assert_mac_gsmtap(packet: &Packet, expected_hexstr: Option<&str>) {
|
||||||
|
assert_eq!(packet.subpackets.len(), 1);
|
||||||
|
let subpacket = &packet.subpackets[0];
|
||||||
|
let result = mac_subpacket_to_gsmtap(&subpacket.body).unwrap();
|
||||||
|
match (result, expected_hexstr) {
|
||||||
|
(Some(msg), Some(hexstr)) => {
|
||||||
|
let (_, data) = unhexlify(hexstr);
|
||||||
|
// SCAT's test cases use GSMTAP v3, but we're on V2, so skip
|
||||||
|
// their GSMTAP header and just compare the payloads
|
||||||
|
let expected_payload = &data.into_inner().into_inner()[34..];
|
||||||
|
assert_eq!(&msg.payload, expected_payload);
|
||||||
|
}
|
||||||
|
(Some(msg), None) => panic!("expected no GSMTAP message, got {msg:?}"),
|
||||||
|
(None, Some(_)) => panic!("expected GSMTAP message, got None"),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mac_rach() {
|
||||||
|
// test data from SCAT unit tests: https://github.com/fgsect/scat/blob/9763cb5b1dcd5ee980f5b0ead9a8d520c8c51a51/tests/test_diagltelogparser.py#L129
|
||||||
|
let test_packets = mac_rach_test_packets_from_scat();
|
||||||
|
assert_mac_gsmtap(
|
||||||
|
&test_packets[0],
|
||||||
|
Some(
|
||||||
|
"03000009040000000000000c0000000012d53d80000000000002000400000000fffe010102015b00411c181a23",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert_mac_gsmtap(
|
||||||
|
&test_packets[1],
|
||||||
|
Some(
|
||||||
|
"03000009040000000000000c0000000012d53d80000000000002000400000000fffe010102015800b0a2b461c6",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert_mac_gsmtap(&test_packets[2], None);
|
||||||
|
assert_mac_gsmtap(
|
||||||
|
&test_packets[3],
|
||||||
|
Some(
|
||||||
|
"03000009040000000000000c0000000012d53d80000000000002000400000ea5fffe010102014a0070e218481c",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert_mac_gsmtap(&test_packets[4], None);
|
||||||
|
assert_mac_gsmtap(
|
||||||
|
&test_packets[5],
|
||||||
|
Some(
|
||||||
|
"03000009040000000000000c0000000012d53d80000000000002000400000d16fffe0101020153005146b45aad",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,9 @@
|
|||||||
use deku::prelude::*;
|
use deku::prelude::*;
|
||||||
use num_enum::TryFromPrimitive;
|
use num_enum::TryFromPrimitive;
|
||||||
|
|
||||||
|
mod mac;
|
||||||
|
pub mod parser;
|
||||||
|
|
||||||
#[derive(Debug, Copy, Clone, PartialEq)]
|
#[derive(Debug, Copy, Clone, PartialEq)]
|
||||||
pub enum GsmtapType {
|
pub enum GsmtapType {
|
||||||
Um(UmSubtype),
|
Um(UmSubtype),
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
use crate::diag::*;
|
use crate::diag::Message;
|
||||||
use crate::gsmtap::*;
|
use crate::diag::diaglog::{LogBody, Nas4GMessageDirection, Timestamp};
|
||||||
|
use crate::gsmtap::mac::mac_subpacket_to_gsmtap;
|
||||||
|
use crate::gsmtap::{GsmtapHeader, GsmtapMessage, GsmtapType, LteNasSubtype, LteRrcSubtype};
|
||||||
|
|
||||||
use log::error;
|
use log::{debug, warn};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
@@ -10,9 +12,14 @@ pub enum GsmtapParserError {
|
|||||||
InvalidLteRrcOtaExtHeaderVersion(u8),
|
InvalidLteRrcOtaExtHeaderVersion(u8),
|
||||||
#[error("Invalid LteRrcOtaMessage header/PDU number combination: {0}/{1}")]
|
#[error("Invalid LteRrcOtaMessage header/PDU number combination: {0}/{1}")]
|
||||||
InvalidLteRrcOtaHeaderPduNum(u8, u8),
|
InvalidLteRrcOtaHeaderPduNum(u8, u8),
|
||||||
|
#[error("Invalid LteMacRachResponse packet: {0}")]
|
||||||
|
InvalidLteMacRachResponse(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse(msg: Message) -> Result<Option<(Timestamp, GsmtapMessage)>, GsmtapParserError> {
|
pub fn parse(msg: Message) -> Result<Option<(Timestamp, GsmtapMessage)>, GsmtapParserError> {
|
||||||
|
if !msg.is_gsmtap_message() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
if let Message::Log {
|
if let Message::Log {
|
||||||
timestamp, body, ..
|
timestamp, body, ..
|
||||||
} = msg
|
} = msg
|
||||||
@@ -27,6 +34,8 @@ pub fn parse(msg: Message) -> Result<Option<(Timestamp, GsmtapMessage)>, GsmtapP
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn log_to_gsmtap(value: LogBody) -> Result<Option<GsmtapMessage>, GsmtapParserError> {
|
fn log_to_gsmtap(value: LogBody) -> Result<Option<GsmtapMessage>, GsmtapParserError> {
|
||||||
|
// Note: if support for another LogBody variant is added here, it should
|
||||||
|
// also be added to Message::is_gsmtap_message
|
||||||
match value {
|
match value {
|
||||||
LogBody::LteRrcOtaMessage {
|
LogBody::LteRrcOtaMessage {
|
||||||
ext_header_version,
|
ext_header_version,
|
||||||
@@ -135,7 +144,7 @@ fn log_to_gsmtap(value: LogBody) -> Result<Option<GsmtapMessage>, GsmtapParserEr
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut header = GsmtapHeader::new(gsmtap_type);
|
let mut header = GsmtapHeader::new(gsmtap_type);
|
||||||
header.arfcn = packet.get_earfcn().try_into().unwrap_or(0);
|
header.arfcn = (packet.get_earfcn() as u16) & 0x3FFF;
|
||||||
header.frame_number = packet.get_sfn();
|
header.frame_number = packet.get_sfn();
|
||||||
header.subslot = packet.get_subfn();
|
header.subslot = packet.get_subfn();
|
||||||
Ok(Some(GsmtapMessage {
|
Ok(Some(GsmtapMessage {
|
||||||
@@ -152,9 +161,48 @@ fn log_to_gsmtap(value: LogBody) -> Result<Option<GsmtapMessage>, GsmtapParserEr
|
|||||||
payload: msg,
|
payload: msg,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
LogBody::LteMacRachResponse { packet } => {
|
||||||
|
if packet.subpackets.len() > 1 {
|
||||||
|
warn!(
|
||||||
|
"expected 1 MAC subpacket for LogBody::LteMacRachResponse, but got {}! ignoring all but the first",
|
||||||
|
packet.subpackets.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let Some(subpacket) = packet.subpackets.first() else {
|
||||||
|
return Err(GsmtapParserError::InvalidLteMacRachResponse(
|
||||||
|
"no subpackets".to_string(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
mac_subpacket_to_gsmtap(&subpacket.body).map_err(|err| {
|
||||||
|
GsmtapParserError::InvalidLteMacRachResponse(format!(
|
||||||
|
"unable to serialize GSMTAP payload: {err:?}"
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
error!("gsmtap_sink: ignoring unhandled log type: {value:?}");
|
debug!("gsmtap_sink: ignoring unhandled log type: {value:?}");
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::gsmtap::GsmtapType;
|
||||||
|
use deku::DekuContainerWrite;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_arfcn_exceeding_14_bits_does_not_panic() {
|
||||||
|
let mut header = GsmtapHeader::new(GsmtapType::LteRrc(LteRrcSubtype::DlDcch));
|
||||||
|
// EARFCN 54540 (band 46) exceeds 14-bit max of 16383
|
||||||
|
let large_earfcn: u32 = 54540;
|
||||||
|
header.arfcn = (large_earfcn as u16) & 0x3FFF;
|
||||||
|
let msg = GsmtapMessage {
|
||||||
|
header,
|
||||||
|
payload: vec![0x00],
|
||||||
|
};
|
||||||
|
// This would panic before the fix with "bit size of input is larger than bit requested size"
|
||||||
|
assert!(msg.to_bytes().is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -15,11 +15,12 @@ pub mod analysis;
|
|||||||
pub mod clock;
|
pub mod clock;
|
||||||
pub mod diag;
|
pub mod diag;
|
||||||
pub mod gsmtap;
|
pub mod gsmtap;
|
||||||
pub mod gsmtap_parser;
|
|
||||||
pub mod hdlc;
|
pub mod hdlc;
|
||||||
pub mod log_codes;
|
pub mod log_codes;
|
||||||
pub mod pcap;
|
pub mod pcap;
|
||||||
pub mod qmdl;
|
pub mod qmdl;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test_util;
|
||||||
pub mod util;
|
pub mod util;
|
||||||
|
|
||||||
// bin/check.rs may target windows and does not use this mod
|
// bin/check.rs may target windows and does not use this mod
|
||||||
|
|||||||
@@ -31,6 +31,16 @@ pub const LOG_NR_RRC_OTA_MSG_LOG_C: u32 = 0xb821;
|
|||||||
// These are 4G-related log types.
|
// These are 4G-related log types.
|
||||||
|
|
||||||
pub const LOG_LTE_RRC_OTA_MSG_LOG_C: u32 = 0xb0c0;
|
pub const LOG_LTE_RRC_OTA_MSG_LOG_C: u32 = 0xb0c0;
|
||||||
|
// Qualcomm ML1 (physical layer) serving cell measurement report: RSRP, RSRQ, RSSI
|
||||||
|
pub const LOG_LTE_ML1_SERVING_CELL_MEAS_AND_EVAL_C: u32 = 0xb17f;
|
||||||
|
pub const LOG_LTE_ML1_SERVING_CELL_MEAS_RESPONSE: u32 = 0xb193;
|
||||||
|
pub const LOG_LTE_ML1_NEIGHBOR_MEAS: u32 = 0xb180;
|
||||||
|
pub const LOG_LTE_ML1_MAC_RAR_MSG1_REPORT: u32 = 0xb167;
|
||||||
|
pub const LOG_LTE_ML1_MAC_RAR_MSG2_REPORT: u32 = 0xb168;
|
||||||
|
// Qualcomm MAC layer RACH response log: contains Timing Advance from Random Access Response
|
||||||
|
pub const LOG_LTE_MAC_RACH_RESPONSE_C: u32 = 0xb062;
|
||||||
|
pub const LOG_LTE_MAC_DL: u32 = 0xb063;
|
||||||
|
pub const LOG_LTE_MAC_UL: u32 = 0xb064;
|
||||||
pub const LOG_LTE_NAS_ESM_OTA_IN_MSG_LOG_C: u32 = 0xb0e2;
|
pub const LOG_LTE_NAS_ESM_OTA_IN_MSG_LOG_C: u32 = 0xb0e2;
|
||||||
pub const LOG_LTE_NAS_ESM_OTA_OUT_MSG_LOG_C: u32 = 0xb0e3;
|
pub const LOG_LTE_NAS_ESM_OTA_OUT_MSG_LOG_C: u32 = 0xb0e3;
|
||||||
pub const LOG_LTE_NAS_EMM_OTA_IN_MSG_LOG_C: u32 = 0xb0ec;
|
pub const LOG_LTE_NAS_EMM_OTA_IN_MSG_LOG_C: u32 = 0xb0ec;
|
||||||
|
|||||||
+21
-3
@@ -1,15 +1,16 @@
|
|||||||
//! Parse QMDL files and create a pcap file.
|
//! Parse QMDL files and create a pcap file.
|
||||||
//! Creates a plausible IP header and [GSMtap](https://osmocom.org/projects/baseband/wiki/GSMTAP) header and then puts the rest of the data under that for wireshark to parse.
|
//! Creates a plausible IP header and [GSMtap](https://osmocom.org/projects/baseband/wiki/GSMTAP) header and then puts the rest of the data under that for wireshark to parse.
|
||||||
use crate::diag::Timestamp;
|
use crate::diag::diaglog::Timestamp;
|
||||||
use crate::gsmtap::GsmtapMessage;
|
use crate::gsmtap::GsmtapMessage;
|
||||||
|
|
||||||
use chrono::prelude::*;
|
use chrono::prelude::*;
|
||||||
use deku::prelude::*;
|
use deku::prelude::*;
|
||||||
use pcap_file_tokio::pcapng::PcapNgWriter;
|
use pcap_file_tokio::pcapng::PcapNgWriter;
|
||||||
use pcap_file_tokio::pcapng::blocks::enhanced_packet::EnhancedPacketBlock;
|
use pcap_file_tokio::pcapng::blocks::enhanced_packet::{EnhancedPacketBlock, EnhancedPacketOption};
|
||||||
use pcap_file_tokio::pcapng::blocks::interface_description::InterfaceDescriptionBlock;
|
use pcap_file_tokio::pcapng::blocks::interface_description::InterfaceDescriptionBlock;
|
||||||
use pcap_file_tokio::pcapng::blocks::section_header::{SectionHeaderBlock, SectionHeaderOption};
|
use pcap_file_tokio::pcapng::blocks::section_header::{SectionHeaderBlock, SectionHeaderOption};
|
||||||
use pcap_file_tokio::{Endianness, PcapError};
|
use pcap_file_tokio::{Endianness, PcapError};
|
||||||
|
use serde::Serialize;
|
||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tokio::io::AsyncWrite;
|
use tokio::io::AsyncWrite;
|
||||||
@@ -26,6 +27,15 @@ pub enum GsmtapPcapError {
|
|||||||
Deku(#[from] DekuError),
|
Deku(#[from] DekuError),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct GpsPoint {
|
||||||
|
pub unix_ts: i64,
|
||||||
|
#[serde(rename = "lat")]
|
||||||
|
pub latitude: f64,
|
||||||
|
#[serde(rename = "lon")]
|
||||||
|
pub longitude: f64,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct GsmtapPcapWriter<T>
|
pub struct GsmtapPcapWriter<T>
|
||||||
where
|
where
|
||||||
T: AsyncWrite,
|
T: AsyncWrite,
|
||||||
@@ -102,6 +112,7 @@ where
|
|||||||
&mut self,
|
&mut self,
|
||||||
msg: GsmtapMessage,
|
msg: GsmtapMessage,
|
||||||
timestamp: Timestamp,
|
timestamp: Timestamp,
|
||||||
|
gps: Option<&GpsPoint>,
|
||||||
) -> Result<(), GsmtapPcapError> {
|
) -> Result<(), GsmtapPcapError> {
|
||||||
let duration = timestamp
|
let duration = timestamp
|
||||||
.to_datetime()
|
.to_datetime()
|
||||||
@@ -137,14 +148,21 @@ where
|
|||||||
data.extend(&ip_header.to_bytes()?);
|
data.extend(&ip_header.to_bytes()?);
|
||||||
data.extend(&udp_header.to_bytes()?);
|
data.extend(&udp_header.to_bytes()?);
|
||||||
data.extend(&msg_bytes);
|
data.extend(&msg_bytes);
|
||||||
|
|
||||||
|
let mut options = vec![];
|
||||||
|
if let Some(p) = gps {
|
||||||
|
let comment = serde_json::to_string(p).expect("GpsPoint serialization cannot fail");
|
||||||
|
options.push(EnhancedPacketOption::Comment(Cow::Owned(comment)));
|
||||||
|
}
|
||||||
let packet = EnhancedPacketBlock {
|
let packet = EnhancedPacketBlock {
|
||||||
interface_id: 0,
|
interface_id: 0,
|
||||||
timestamp: duration,
|
timestamp: duration,
|
||||||
original_len: data.len() as u32,
|
original_len: data.len() as u32,
|
||||||
data: Cow::Owned(data),
|
data: Cow::Owned(data),
|
||||||
options: vec![],
|
options,
|
||||||
};
|
};
|
||||||
self.writer.write_pcapng_block(packet).await?;
|
self.writer.write_pcapng_block(packet).await?;
|
||||||
|
|
||||||
self.ip_id = self.ip_id.wrapping_add(1);
|
self.ip_id = self.ip_id.wrapping_add(1);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
+312
-147
@@ -3,109 +3,230 @@
|
|||||||
//! QmdlReader and QmdlWriter can read and write MessagesContainers to and from
|
//! QmdlReader and QmdlWriter can read and write MessagesContainers to and from
|
||||||
//! QMDL files.
|
//! QMDL files.
|
||||||
|
|
||||||
use crate::diag::{DataType, HdlcEncapsulatedMessage, MESSAGE_TERMINATOR, MessagesContainer};
|
use std::io::ErrorKind;
|
||||||
|
use std::pin::Pin;
|
||||||
|
use std::task::Poll;
|
||||||
|
|
||||||
|
use crate::diag::{DiagParsingError, MESSAGE_TERMINATOR, Message, MessagesContainer};
|
||||||
|
|
||||||
|
use async_compression::tokio::bufread::GzipDecoder;
|
||||||
|
use async_compression::tokio::write::GzipEncoder;
|
||||||
use futures::TryStream;
|
use futures::TryStream;
|
||||||
use log::error;
|
use tokio::io::{
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
|
AsyncBufReadExt, AsyncRead, AsyncReadExt, AsyncSeek, AsyncSeekExt, AsyncWrite, AsyncWriteExt,
|
||||||
|
BufReader,
|
||||||
|
};
|
||||||
|
|
||||||
|
const GZIP_MAGIC_NUMBER: u16 = 0x1f8b;
|
||||||
|
|
||||||
pub struct QmdlWriter<T>
|
pub struct QmdlWriter<T>
|
||||||
where
|
where
|
||||||
T: AsyncWrite + Unpin,
|
T: AsyncWrite + Unpin,
|
||||||
{
|
{
|
||||||
writer: T,
|
writer: GzipEncoder<T>,
|
||||||
pub total_written: usize,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T> QmdlWriter<T>
|
impl<T> QmdlWriter<T>
|
||||||
where
|
where
|
||||||
T: AsyncWrite + Unpin,
|
T: AsyncWrite + AsyncSeek + Unpin,
|
||||||
{
|
{
|
||||||
pub fn new(writer: T) -> Self {
|
pub fn new(writer: T) -> Self {
|
||||||
QmdlWriter::new_with_existing_size(writer, 0)
|
let gzip_writer = GzipEncoder::new(writer);
|
||||||
|
QmdlWriter {
|
||||||
|
writer: gzip_writer,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new_with_existing_size(writer: T, existing_size: usize) -> Self {
|
pub async fn size(&mut self) -> std::io::Result<usize> {
|
||||||
QmdlWriter {
|
let size = self.writer.get_mut().stream_position().await?;
|
||||||
writer,
|
Ok(size as usize)
|
||||||
total_written: existing_size,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn write_container(&mut self, container: &MessagesContainer) -> std::io::Result<()> {
|
pub async fn write_container(&mut self, container: &MessagesContainer) -> std::io::Result<()> {
|
||||||
for msg in &container.messages {
|
for msg in &container.messages {
|
||||||
self.writer.write_all(&msg.data).await?;
|
self.writer.write_all(&msg.data).await?;
|
||||||
self.total_written += msg.data.len();
|
|
||||||
}
|
}
|
||||||
|
self.writer.flush().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn close(mut self) -> std::io::Result<usize> {
|
||||||
|
self.writer.shutdown().await?;
|
||||||
|
self.size().await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct QmdlReader<T>
|
#[derive(Debug)]
|
||||||
|
enum QmdlReaderSource<T> {
|
||||||
|
Compressed {
|
||||||
|
reader: GzipDecoder<BufReader<T>>,
|
||||||
|
eof: bool,
|
||||||
|
},
|
||||||
|
Uncompressed {
|
||||||
|
reader: T,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct QmdlAsyncReader<T> {
|
||||||
|
source: QmdlReaderSource<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> QmdlAsyncReader<T>
|
||||||
where
|
where
|
||||||
T: AsyncRead,
|
T: AsyncRead,
|
||||||
{
|
{
|
||||||
reader: BufReader<T>,
|
pub fn new(reader: T, compressed: bool) -> Self {
|
||||||
bytes_read: usize,
|
let source = if compressed {
|
||||||
max_bytes: Option<usize>,
|
QmdlReaderSource::Compressed {
|
||||||
|
reader: GzipDecoder::new(BufReader::new(reader)),
|
||||||
|
eof: false,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
QmdlReaderSource::Uncompressed { reader }
|
||||||
|
};
|
||||||
|
Self { source }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T> QmdlReader<T>
|
impl<T> AsyncRead for QmdlAsyncReader<T>
|
||||||
where
|
where
|
||||||
T: AsyncRead + Unpin,
|
T: AsyncRead + Unpin,
|
||||||
{
|
{
|
||||||
pub fn new(reader: T, max_bytes: Option<usize>) -> Self {
|
fn poll_read(
|
||||||
QmdlReader {
|
self: Pin<&mut Self>,
|
||||||
reader: BufReader::new(reader),
|
cx: &mut std::task::Context<'_>,
|
||||||
bytes_read: 0,
|
buf: &mut tokio::io::ReadBuf<'_>,
|
||||||
max_bytes,
|
) -> Poll<std::io::Result<()>> {
|
||||||
|
match &mut self.get_mut().source {
|
||||||
|
QmdlReaderSource::Compressed { reader, eof } => {
|
||||||
|
// if we already determined we've reached the Gzip EOF, don't read more
|
||||||
|
if *eof {
|
||||||
|
return Poll::Ready(Ok(()));
|
||||||
|
}
|
||||||
|
|
||||||
|
match Pin::new(reader).poll_read(cx, buf) {
|
||||||
|
// if we hit an unexpected EOF in a Gzip file, it shouldn't
|
||||||
|
// be considered fatal, just a truncated file. mark that
|
||||||
|
// we're done and return the result as usual
|
||||||
|
Poll::Ready(Err(err)) if err.kind() == ErrorKind::UnexpectedEof => {
|
||||||
|
*eof = true;
|
||||||
|
Poll::Ready(Ok(()))
|
||||||
|
}
|
||||||
|
res => res,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
QmdlReaderSource::Uncompressed { reader } => Pin::new(reader).poll_read(cx, buf),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn as_stream(
|
#[derive(Debug)]
|
||||||
&mut self,
|
pub struct QmdlMessageReader<T>
|
||||||
) -> impl TryStream<Ok = MessagesContainer, Error = std::io::Error> + '_ {
|
where
|
||||||
futures::stream::try_unfold(self, |reader| async {
|
T: AsyncRead,
|
||||||
let maybe_container = reader.get_next_messages_container().await?;
|
{
|
||||||
match maybe_container {
|
buf_reader: BufReader<QmdlAsyncReader<T>>,
|
||||||
Some(container) => Ok(Some((container, reader))),
|
}
|
||||||
|
|
||||||
|
async fn is_gzip_stream<T>(mut reader: T) -> std::io::Result<bool>
|
||||||
|
where
|
||||||
|
T: AsyncRead + AsyncSeek + Unpin,
|
||||||
|
{
|
||||||
|
let magic_number = reader.read_u16().await?;
|
||||||
|
reader.rewind().await?;
|
||||||
|
// this is safe because 0x1f8b.... doesn't overlap with any known
|
||||||
|
// diag::DataType values
|
||||||
|
Ok(magic_number == GZIP_MAGIC_NUMBER)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> QmdlMessageReader<T>
|
||||||
|
where
|
||||||
|
T: AsyncRead + AsyncSeek + Unpin,
|
||||||
|
{
|
||||||
|
pub async fn new(mut reader: T) -> std::io::Result<Self> {
|
||||||
|
let compressed = is_gzip_stream(&mut reader).await.unwrap_or(false);
|
||||||
|
Ok(QmdlMessageReader {
|
||||||
|
buf_reader: BufReader::new(QmdlAsyncReader::new(reader, compressed)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_compressed(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self.buf_reader.get_ref().source,
|
||||||
|
QmdlReaderSource::Compressed { .. }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_qmdl_stream(self) -> impl TryStream<Ok = Vec<u8>, Error = std::io::Error> {
|
||||||
|
futures::stream::try_unfold(self, |mut reader| async {
|
||||||
|
let mut buf = vec![];
|
||||||
|
match reader
|
||||||
|
.buf_reader
|
||||||
|
.read_until(MESSAGE_TERMINATOR, &mut buf)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Err(err) => Err(err),
|
||||||
|
Ok(0) => Ok(None),
|
||||||
|
Ok(_) => Ok(Some((buf, reader))),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_message_stream(
|
||||||
|
self,
|
||||||
|
) -> impl TryStream<Ok = Result<Message, DiagParsingError>, Error = std::io::Error> {
|
||||||
|
futures::stream::try_unfold(self, |mut reader| async {
|
||||||
|
match reader.get_next_message().await? {
|
||||||
|
Some(res) => Ok(Some((res, reader))),
|
||||||
None => Ok(None),
|
None => Ok(None),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_next_messages_container(
|
pub async fn get_next_message(
|
||||||
&mut self,
|
&mut self,
|
||||||
) -> Result<Option<MessagesContainer>, std::io::Error> {
|
) -> Result<Option<Result<Message, DiagParsingError>>, std::io::Error> {
|
||||||
if let Some(max_bytes) = self.max_bytes
|
let mut buf = vec![];
|
||||||
&& self.bytes_read >= max_bytes
|
if self
|
||||||
|
.buf_reader
|
||||||
|
.read_until(MESSAGE_TERMINATOR, &mut buf)
|
||||||
|
.await?
|
||||||
|
== 0
|
||||||
{
|
{
|
||||||
if self.bytes_read > max_bytes {
|
|
||||||
error!(
|
|
||||||
"warning: {} bytes read, but max_bytes was {}",
|
|
||||||
self.bytes_read, max_bytes
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut buf = Vec::new();
|
Ok(Some(Message::from_hdlc(&buf)))
|
||||||
let bytes_read = self.reader.read_until(MESSAGE_TERMINATOR, &mut buf).await?;
|
}
|
||||||
self.bytes_read += bytes_read;
|
|
||||||
|
|
||||||
// Since QMDL is just a flat list of messages, we can't actually
|
pub async fn get_next_buf(
|
||||||
// reproduce the container structure they came from in the original
|
&mut self,
|
||||||
// read. So we'll just pretend that all containers had exactly one
|
) -> Result<Option<Vec<u8>>, std::io::Error> {
|
||||||
// message. As far as I know, the number of messages per container
|
let mut buf = vec![];
|
||||||
// doesn't actually affect anything, so this should be fine.
|
if self
|
||||||
Ok(Some(MessagesContainer {
|
.buf_reader
|
||||||
data_type: DataType::UserSpace,
|
.read_until(MESSAGE_TERMINATOR, &mut buf)
|
||||||
num_messages: 1,
|
.await?
|
||||||
messages: vec![HdlcEncapsulatedMessage {
|
== 0
|
||||||
len: bytes_read as u32,
|
{
|
||||||
data: buf,
|
return Ok(None);
|
||||||
}],
|
}
|
||||||
}))
|
|
||||||
|
Ok(Some(buf))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> AsyncRead for QmdlMessageReader<T>
|
||||||
|
where
|
||||||
|
T: AsyncRead + Unpin,
|
||||||
|
{
|
||||||
|
fn poll_read(
|
||||||
|
self: Pin<&mut Self>,
|
||||||
|
cx: &mut std::task::Context<'_>,
|
||||||
|
buf: &mut tokio::io::ReadBuf<'_>,
|
||||||
|
) -> Poll<std::io::Result<()>> {
|
||||||
|
Pin::new(&mut self.get_mut().buf_reader).poll_read(cx, buf)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,132 +234,176 @@ where
|
|||||||
mod test {
|
mod test {
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
|
|
||||||
use crate::diag::CRC_CCITT;
|
use crate::diag::{DataType, HdlcEncapsulatedMessage, diaglog::test::get_test_message};
|
||||||
use crate::hdlc::hdlc_encapsulate;
|
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn get_test_messages() -> Vec<HdlcEncapsulatedMessage> {
|
fn get_test_messages() -> (Vec<HdlcEncapsulatedMessage>, Vec<Message>) {
|
||||||
let messages: Vec<HdlcEncapsulatedMessage> = (10..20)
|
let mut hdlcs = Vec::new();
|
||||||
.map(|i| {
|
let mut messages = Vec::new();
|
||||||
let data = hdlc_encapsulate(&vec![i as u8; i], &CRC_CCITT);
|
for i in 10..20 {
|
||||||
HdlcEncapsulatedMessage {
|
let (hdlc, msg) = get_test_message(&[i]);
|
||||||
len: data.len() as u32,
|
hdlcs.push(hdlc);
|
||||||
data,
|
messages.push(msg);
|
||||||
}
|
}
|
||||||
})
|
(hdlcs, messages)
|
||||||
.collect();
|
|
||||||
messages
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// returns a byte array consisting of concatenated HDLC encapsulated
|
// returns a byte array consisting of concatenated HDLC encapsulated
|
||||||
// test messages
|
// test messages
|
||||||
fn get_test_message_bytes() -> Vec<u8> {
|
fn get_test_message_bytes() -> Vec<u8> {
|
||||||
get_test_messages()
|
let (hdlcs, _) = get_test_messages();
|
||||||
.iter()
|
hdlcs.iter().flat_map(|msg| msg.data.clone()).collect()
|
||||||
.flat_map(|msg| msg.data.clone())
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_test_containers() -> Vec<MessagesContainer> {
|
fn get_test_containers() -> Vec<MessagesContainer> {
|
||||||
let messages = get_test_messages();
|
let (hdlcs, _) = get_test_messages();
|
||||||
let (messages1, messages2) = messages.split_at(5);
|
let (hdlcs1, hdlcs2) = hdlcs.split_at(5);
|
||||||
vec![
|
vec![
|
||||||
MessagesContainer {
|
MessagesContainer {
|
||||||
data_type: DataType::UserSpace,
|
data_type: DataType::UserSpace,
|
||||||
num_messages: messages1.len() as u32,
|
num_messages: hdlcs1.len() as u32,
|
||||||
messages: messages1.to_vec(),
|
messages: hdlcs1.to_vec(),
|
||||||
},
|
},
|
||||||
MessagesContainer {
|
MessagesContainer {
|
||||||
data_type: DataType::UserSpace,
|
data_type: DataType::UserSpace,
|
||||||
num_messages: messages2.len() as u32,
|
num_messages: hdlcs2.len() as u32,
|
||||||
messages: messages2.to_vec(),
|
messages: hdlcs2.to_vec(),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_unbounded_qmdl_reader() {
|
async fn test_qmdl_reader() {
|
||||||
let mut buf = Cursor::new(get_test_message_bytes());
|
let mut buf = Cursor::new(get_test_message_bytes());
|
||||||
let mut reader = QmdlReader::new(&mut buf, None);
|
let mut reader = QmdlMessageReader::new(&mut buf).await.unwrap();
|
||||||
let expected_messages = get_test_messages();
|
assert!(!reader.is_compressed());
|
||||||
for message in expected_messages {
|
let (_, expected_messages) = get_test_messages();
|
||||||
let expected_container = MessagesContainer {
|
for msg in expected_messages {
|
||||||
data_type: DataType::UserSpace,
|
assert_eq!(Ok(msg), reader.get_next_message().await.unwrap().unwrap());
|
||||||
num_messages: 1,
|
|
||||||
messages: vec![message],
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
expected_container,
|
|
||||||
reader.get_next_messages_container().await.unwrap().unwrap()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_bounded_qmdl_reader() {
|
async fn test_truncation() {
|
||||||
let mut buf = Cursor::new(get_test_message_bytes());
|
run_truncation_tests(false).await;
|
||||||
|
|
||||||
// bound the reader to the first two messages
|
|
||||||
let mut expected_messages = get_test_messages();
|
|
||||||
let limit = expected_messages[0].len + expected_messages[1].len;
|
|
||||||
|
|
||||||
let mut reader = QmdlReader::new(&mut buf, Some(limit as usize));
|
|
||||||
for message in expected_messages.drain(0..2) {
|
|
||||||
let expected_container = MessagesContainer {
|
|
||||||
data_type: DataType::UserSpace,
|
|
||||||
num_messages: 1,
|
|
||||||
messages: vec![message],
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
expected_container,
|
|
||||||
reader.get_next_messages_container().await.unwrap().unwrap()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assert!(matches!(
|
|
||||||
reader.get_next_messages_container().await,
|
|
||||||
Ok(None)
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_qmdl_writer() {
|
async fn test_compressed_truncation() {
|
||||||
|
run_truncation_tests(true).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_truncation_tests(compressed: bool) {
|
||||||
|
let (hdlcs, expected_messages) = get_test_messages();
|
||||||
|
let (bytes, message_lengths): (Vec<u8>, Vec<usize>) = if compressed {
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
let mut writer = QmdlWriter::new(&mut buf);
|
let mut compressed_lengths = Vec::new();
|
||||||
let expected_containers = get_test_containers();
|
let mut writer = GzipEncoder::new(&mut buf);
|
||||||
for container in &expected_containers {
|
for hdlc in &hdlcs {
|
||||||
writer.write_container(container).await.unwrap();
|
let before = writer.get_ref().len();
|
||||||
|
writer.write_all(&hdlc.data).await.unwrap();
|
||||||
|
writer.flush().await.unwrap();
|
||||||
|
let after = writer.get_ref().len();
|
||||||
|
compressed_lengths.push(after - before);
|
||||||
}
|
}
|
||||||
assert_eq!(writer.total_written, buf.len());
|
(buf, compressed_lengths)
|
||||||
assert_eq!(buf, get_test_message_bytes());
|
} else {
|
||||||
|
(
|
||||||
|
get_test_message_bytes(),
|
||||||
|
hdlcs.iter().map(|hdlc| hdlc.data.len()).collect(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
for truncated_hdlc_i in 1..hdlcs.len() {
|
||||||
|
let whole_bytes: usize = message_lengths.iter().take(truncated_hdlc_i).sum();
|
||||||
|
for truncated_byte in 1..message_lengths[truncated_hdlc_i] {
|
||||||
|
let mut truncated_bytes = Cursor::new(&bytes[0..whole_bytes + truncated_byte]);
|
||||||
|
let mut reader = QmdlMessageReader::new(&mut truncated_bytes).await.unwrap();
|
||||||
|
for msg in expected_messages.iter().take(truncated_hdlc_i) {
|
||||||
|
assert_eq!(
|
||||||
|
Ok(msg),
|
||||||
|
reader.get_next_message().await.unwrap().unwrap().as_ref()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if compressed {
|
||||||
|
// for a compressed reader, we have a couple possible
|
||||||
|
// outcomes, depending on how far along the Gzip DEFLATE
|
||||||
|
// block was before it was truncated:
|
||||||
|
match reader.get_next_message().await.unwrap() {
|
||||||
|
// if the block was truncated early enough, the
|
||||||
|
// GzipDecoder will detect an unexpected EOF, and our
|
||||||
|
// QmdlReader will indicate the stream of messages is
|
||||||
|
// done
|
||||||
|
None => {}
|
||||||
|
// if it's further along, the expanded result will be an
|
||||||
|
// invalid HDLC block. if that's the case, make sure the
|
||||||
|
// QmdlReader indicates the stream of messages is over
|
||||||
|
// with afterwards
|
||||||
|
Some(Err(DiagParsingError::HdlcDecapsulationError(_, _))) => {
|
||||||
|
assert!(matches!(reader.get_next_message().await, Ok(None)));
|
||||||
|
}
|
||||||
|
// if it's further along still, we may get a complete
|
||||||
|
// Message, so make sure it matches the next expected
|
||||||
|
// one. then, make sure we've hit the end of the message
|
||||||
|
// stream
|
||||||
|
Some(Ok(msg)) => {
|
||||||
|
assert_eq!(&msg, &expected_messages[truncated_hdlc_i]);
|
||||||
|
assert!(matches!(reader.get_next_message().await, Ok(None)));
|
||||||
|
}
|
||||||
|
// we should never be able to decapsulate the HDLC into
|
||||||
|
// an invalid Diag message
|
||||||
|
Some(Err(DiagParsingError::MessageParsingError(_, _))) => {
|
||||||
|
panic!("unexpected MessageParsingError");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// a truncated uncompressed reader should always end on an
|
||||||
|
// HdlcDecapsulationError, and then return Ok(None) to
|
||||||
|
// indicate the message stream is over
|
||||||
|
assert!(matches!(
|
||||||
|
reader.get_next_message().await,
|
||||||
|
Ok(Some(Err(DiagParsingError::HdlcDecapsulationError(_, _))))
|
||||||
|
));
|
||||||
|
assert!(matches!(reader.get_next_message().await, Ok(None)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes the test containers to a QmdlWriter, optionally finishing the
|
||||||
|
/// gzip stream with a footer. Then, attempts to decompress the buffer with
|
||||||
|
/// a QmdlWriter, asserting that the containers match what's expected.
|
||||||
|
async fn run_compressed_reading_and_writing_tests(do_close: bool) {
|
||||||
|
let containers = get_test_containers();
|
||||||
|
let mut buf = Cursor::new(Vec::new());
|
||||||
|
let writer_size = {
|
||||||
|
let mut writer = QmdlWriter::new(&mut buf);
|
||||||
|
for container in &containers {
|
||||||
|
writer.write_container(&container).await.unwrap();
|
||||||
|
}
|
||||||
|
if do_close {
|
||||||
|
writer.close().await.unwrap()
|
||||||
|
} else {
|
||||||
|
writer.size().await.unwrap()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
assert_eq!(buf.position() as usize, writer_size);
|
||||||
|
buf.set_position(0);
|
||||||
|
let mut reader = QmdlMessageReader::new(buf).await.unwrap();
|
||||||
|
assert!(reader.is_compressed());
|
||||||
|
let (_, expected_messages) = get_test_messages();
|
||||||
|
for message in expected_messages {
|
||||||
|
assert_eq!(
|
||||||
|
Ok(message),
|
||||||
|
reader.get_next_message().await.unwrap().unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(matches!(reader.get_next_message().await, Ok(None)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_writing_and_reading() {
|
async fn test_compressed_reading_and_writing() {
|
||||||
let mut buf = Vec::new();
|
run_compressed_reading_and_writing_tests(true).await;
|
||||||
let mut writer = QmdlWriter::new(&mut buf);
|
run_compressed_reading_and_writing_tests(false).await;
|
||||||
let expected_containers = get_test_containers();
|
|
||||||
for container in &expected_containers {
|
|
||||||
writer.write_container(container).await.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
let limit = Some(buf.len());
|
|
||||||
let mut reader = QmdlReader::new(Cursor::new(&mut buf), limit);
|
|
||||||
let expected_messages = get_test_messages();
|
|
||||||
for message in expected_messages {
|
|
||||||
let expected_container = MessagesContainer {
|
|
||||||
data_type: DataType::UserSpace,
|
|
||||||
num_messages: 1,
|
|
||||||
messages: vec![message],
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
expected_container,
|
|
||||||
reader.get_next_messages_container().await.unwrap().unwrap()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assert!(matches!(
|
|
||||||
reader.get_next_messages_container().await,
|
|
||||||
Ok(None)
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
use deku::reader::Reader;
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
pub fn unhexlify(hexlified_bytes: &str) -> (usize, Reader<Cursor<Vec<u8>>>) {
|
||||||
|
let byte_len = hexlified_bytes.len() / 2;
|
||||||
|
let bytes = (0..hexlified_bytes.len())
|
||||||
|
.step_by(2)
|
||||||
|
.map(|i| u8::from_str_radix(&hexlified_bytes[i..i + 2], 16).unwrap())
|
||||||
|
.collect();
|
||||||
|
(byte_len, Reader::new(Cursor::new(bytes)))
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
use deku::prelude::*;
|
use deku::prelude::*;
|
||||||
use rayhunter::{
|
use rayhunter::{
|
||||||
diag::{LogBody, LteRrcOtaPacket, Message, Timestamp},
|
diag::Message,
|
||||||
gsmtap_parser,
|
diag::diaglog::{LogBody, Timestamp, rrc::LteRrcOtaPacket},
|
||||||
|
gsmtap::parser as gsmtap_parser,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Tests here are based on https://github.com/fgsect/scat/blob/97442580e628de414c9f7c2a185f4e28d0ee7523/tests/test_diagltelogparser.py
|
// Tests here are based on https://github.com/fgsect/scat/blob/97442580e628de414c9f7c2a185f4e28d0ee7523/tests/test_diagltelogparser.py
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rootshell"
|
name = "rootshell"
|
||||||
version = "0.11.0"
|
version = "0.11.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|||||||
@@ -39,10 +39,9 @@ cd "$BUILD_DIR/libnl-${LIBNL_VERSION}"
|
|||||||
--enable-static \
|
--enable-static \
|
||||||
--disable-shared \
|
--disable-shared \
|
||||||
--disable-cli \
|
--disable-cli \
|
||||||
--disable-debug \
|
--disable-debug
|
||||||
> /dev/null 2>&1
|
make -j"$(nproc 2>/dev/null || sysctl -n hw.ncpu)"
|
||||||
make -j"$(nproc 2>/dev/null || sysctl -n hw.ncpu)" > /dev/null 2>&1
|
make install
|
||||||
make install > /dev/null 2>&1
|
|
||||||
|
|
||||||
echo "Building wpa_supplicant ${WPA_VERSION}..."
|
echo "Building wpa_supplicant ${WPA_VERSION}..."
|
||||||
cd "$BUILD_DIR"
|
cd "$BUILD_DIR"
|
||||||
|
|||||||
Executable
+32
@@ -0,0 +1,32 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Sets Rayhunter package versions in preparation for a release.
|
||||||
|
#
|
||||||
|
# Usage: ./scripts/set-versions.sh VERSION_NUM
|
||||||
|
# Example: ./scripts/set-versions.sh 0.12.3
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
|
||||||
|
if [ -z "$1" ]; then
|
||||||
|
echo "Error: Missing required version number argument."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SED_COMMAND="s/^version = \".*\"/version = \"$1\"/"
|
||||||
|
TOML_FILES=(*/Cargo.toml installer-gui/src-tauri/Cargo.toml)
|
||||||
|
|
||||||
|
echo "Updating Cargo.toml files"
|
||||||
|
if sed --version > /dev/null 2>&1; then
|
||||||
|
# we have GNU sed
|
||||||
|
sed -i -E "$SED_COMMAND" "${TOML_FILES[@]}"
|
||||||
|
else
|
||||||
|
# we have macOS/BSD sed
|
||||||
|
sed -i "" -E "$SED_COMMAND" "${TOML_FILES[@]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Updating Cargo.lock"
|
||||||
|
cargo update --workspace
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "telcom-parser"
|
name = "telcom-parser"
|
||||||
version = "0.11.0"
|
version = "0.11.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|||||||
Reference in New Issue
Block a user