From 2a1adbb88c8692cc2a149a26913c4b2b2f4879a0 Mon Sep 17 00:00:00 2001 From: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:53:48 +0200 Subject: [PATCH 1/3] chore: update gitignore for AI config and build artifacts Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com> --- .gitignore | 6 ++++ project-context.md | 77 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 project-context.md diff --git a/.gitignore b/.gitignore index 1e3967f..1ab0d5e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,11 @@ *.bmap *.wic* *.swu* +*.xml .vscode/launch.json docker-run.sh +setup-ai.sh +.user-context.md +CLAUDE.md +.gitignore +GEMINI.md diff --git a/project-context.md b/project-context.md new file mode 100644 index 0000000..f1dd9ea --- /dev/null +++ b/project-context.md @@ -0,0 +1,77 @@ + + +# Project Context + +## 1. Role & Responsibility + +- **Role:** CLI tool to configure omnect-os firmware images (partition manipulation, identity injection, Docker container embedding) and communicate with omnect-os devices (SSH tunneling, Azure Device Update import/removal). +- **Runtime Target:** Developer workstation (native or Docker container via distroless image; `CONTAINERIZED` env var toggles container-aware behavior). + +## 2. Architecture & Tech Stack + +- **Language / Runtime:** Rust (edition 2024), async via Tokio + Actix-web +- **Key Frameworks:** clap 4.5 (derive) for CLI, actix-web for local OAuth2 redirect server, reqwest for HTTP +- **Notable Dependencies:** + - Azure SDK from **omnect fork** (`omnect/azure-sdk-for-rust`) — blocked on upstream PR #1636 + - `omnect-crypto` 0.4.0 for device certificate creation + - `gptman`/`mbrman` for partition table parsing (GPT+MBR) + - `filemagic` for compression auto-detection + - `keyring` for system credential storage (OAuth refresh tokens) + - `e2tools` + `mtools` (external binaries) for ext4/FAT32 partition file operations + - `anyhow` for error handling (CLI tool, ergonomics over type precision) + +## 3. Key Entry Points & Files + +- `src/main.rs` — binary entry point; sets up env_logger, calls `omnect_cli::run()` +- `src/lib.rs` — command dispatch hub; `run()` parses CLI args and delegates to handlers +- `src/cli.rs` — clap `Parser`/`Subcommand` definitions for all commands +- `src/auth.rs` — OAuth2 PKCE flow against Keycloak (local redirect server on :4000) +- `src/config.rs` — Keycloak provider config, backend URL constants +- `src/ssh.rs` — SSH tunnel creation via bastion host, ed25519 key generation +- `src/device_update.rs` — Azure Device Update import manifest creation, import/remove +- `src/docker.rs` — `docker pull --platform` + `docker save` for multi-arch images +- `src/image.rs` — firmware image architecture detection (ARM32/ARM64/x86_64) +- `src/file/mod.rs` — high-level image operations: identity config, certs, hostname patching +- `src/file/functions.rs` — partition read/modify/write via dd + e2cp/mcopy +- `src/file/partition.rs` — GPT/MBR partition table parsing +- `src/file/compression.rs` — xz/bzip2/gzip compress/decompress with auto-detection +- `src/validators/` — validation for identity config (TOML), device-update config (JSON), SSH keys +- `conf/*.template` — 11 config templates (identity, device-update, WiFi) + +## 4. Repository-Specific Constraints + +- External tools `e2cp`, `e2mkdir`, `mcopy`, `mmd`, `dd`, `fallocate`, `ssh-keygen`, `fdisk` must be available at runtime (Dockerfile copies them explicitly). +- Partition enum maps partition names to numbers differently for GPT vs MBR — see `file/functions.rs`. +- OAuth2 callback binds to `127.0.0.1:4000` and `[::1]:4000`; container mode overrides to `0.0.0.0`. +- `conf/` directory uses `.gitignore` to track only `*.template` files — actual configs are generated, never committed. +- Azure SDK crates use a **fork** (`omnect/azure-sdk-for-rust`) with `default-features = false`. Do not switch to upstream until PR #1636 is merged. + +## 5. Local Dev Scripts + +- **Build:** `cargo build` / `cargo build --release` +- **Run Tests:** `cargo test` (unit + integration; integration tests create temp dirs under `/tmp/omnect-cli-integration-tests/`) +- **Lint:** `cargo clippy` / `cargo fmt --check` +- **Debian Package:** `cargo deb` (requires `cargo-deb`; output in `target/debian/`) +- **Docker Image:** `./build-image.sh` (extracts version from Cargo.toml, builds distroless image) + +## 6. Global Rule Overrides + +- Uses `anyhow` instead of custom error types — this is a CLI tool where error ergonomics outweigh type precision. From e69fbffaa78effc0e0a7d83ce432a15ddbd66e8c Mon Sep 17 00:00:00 2001 From: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:31:24 +0200 Subject: [PATCH 2/3] refactor(device_update): replace azure-sdk fork with local implementation The upstream azure-sdk-for-rust removed azure_iot_deviceupdate from its workspace and no longer supports SAS URL generation in azure_storage_blob. This replaces all 5 azure SDK fork dependencies with local implementations using crates already in the dependency tree (oauth2, reqwest, hmac, sha2). New module structure under src/device_update/: - token.rs: OAuth2 client_credentials flow with token caching - client.rs: ADU REST client (import + delete with exponential backoff) - blob_sas.rs: HMAC-SHA256 Service SAS URL generation Also bumps tar dev-dependency to 0.4.45 to fix RUSTSEC-2026-0067/0068. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com> --- Cargo.lock | 605 ++---------------- Cargo.toml | 21 +- src/device_update/blob_sas.rs | 128 ++++ src/device_update/client.rs | 230 +++++++ .../mod.rs} | 108 ++-- src/device_update/token.rs | 89 +++ src/main.rs | 27 +- 7 files changed, 557 insertions(+), 651 deletions(-) create mode 100644 src/device_update/blob_sas.rs create mode 100644 src/device_update/client.rs rename src/{device_update.rs => device_update/mod.rs} (76%) create mode 100644 src/device_update/token.rs diff --git a/Cargo.lock b/Cargo.lock index 82e37f2..a5f456e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "RustyXML" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b5ace29ee3216de37c0546865ad08edef58b0f9e76838ed8959a84a990e58c5" - [[package]] name = "actix-codec" version = "0.5.2" @@ -35,7 +29,7 @@ dependencies = [ "actix-rt", "actix-service", "actix-utils", - "base64 0.22.1", + "base64", "bitflags", "brotli", "bytes", @@ -267,54 +261,13 @@ dependencies = [ "wait-timeout", ] -[[package]] -name = "async-channel" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" -dependencies = [ - "concurrent-queue", - "event-listener 2.5.3", - "futures-core", -] - -[[package]] -name = "async-channel" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-io" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" -dependencies = [ - "autocfg", - "cfg-if", - "concurrent-queue", - "futures-io", - "futures-lite 2.6.1", - "parking", - "polling", - "rustix", - "slab", - "windows-sys 0.61.2", -] - [[package]] name = "async-lock" version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" dependencies = [ - "event-listener 5.4.1", + "event-listener", "event-listener-strategy", "pin-project-lite", ] @@ -326,51 +279,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1ac0219111eb7bb7cb76d4cf2cb50c598e7ae549091d3616f9e95442c18486f" dependencies = [ "async-lock", - "event-listener 5.4.1", -] - -[[package]] -name = "async-process" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" -dependencies = [ - "async-channel 2.5.0", - "async-io", - "async-lock", - "async-signal", - "async-task", - "blocking", - "cfg-if", - "event-listener 5.4.1", - "futures-lite 2.6.1", - "rustix", + "event-listener", ] -[[package]] -name = "async-signal" -version = "0.2.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" -dependencies = [ - "async-io", - "async-lock", - "atomic-waker", - "cfg-if", - "futures-core", - "futures-io", - "rustix", - "signal-hook-registry", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - [[package]] name = "async-trait" version = "0.1.89" @@ -394,126 +305,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "azure_core" -version = "0.19.0" -source = "git+https://github.com/omnect/azure-sdk-for-rust.git#ccb2b176813c9b6ff4ff1179ae9419fb3327036d" -dependencies = [ - "async-trait", - "base64 0.22.1", - "bytes", - "dyn-clone", - "futures", - "getrandom 0.2.17", - "http-types", - "once_cell", - "paste", - "pin-project", - "quick-xml", - "rand 0.8.5", - "rustc_version", - "serde", - "serde_json", - "time", - "tracing", - "url", - "uuid", -] - -[[package]] -name = "azure_identity" -version = "0.19.0" -source = "git+https://github.com/omnect/azure-sdk-for-rust.git#ccb2b176813c9b6ff4ff1179ae9419fb3327036d" -dependencies = [ - "async-lock", - "async-process", - "async-trait", - "azure_core", - "futures", - "oauth2 4.4.2", - "pin-project", - "serde", - "time", - "tracing", - "url", - "uuid", -] - -[[package]] -name = "azure_iot_deviceupdate" -version = "0.19.0" -source = "git+https://github.com/omnect/azure-sdk-for-rust.git#ccb2b176813c9b6ff4ff1179ae9419fb3327036d" -dependencies = [ - "azure_core", - "azure_identity", - "const_format", - "getset", - "reqwest 0.12.28", - "serde", - "serde_json", - "time", - "tracing", -] - -[[package]] -name = "azure_storage" -version = "0.19.0" -source = "git+https://github.com/omnect/azure-sdk-for-rust.git#ccb2b176813c9b6ff4ff1179ae9419fb3327036d" -dependencies = [ - "RustyXML", - "async-lock", - "async-trait", - "azure_core", - "bytes", - "serde", - "serde_derive", - "time", - "tracing", - "url", - "uuid", -] - -[[package]] -name = "azure_storage_blobs" -version = "0.19.0" -source = "git+https://github.com/omnect/azure-sdk-for-rust.git#ccb2b176813c9b6ff4ff1179ae9419fb3327036d" -dependencies = [ - "RustyXML", - "azure_core", - "azure_storage", - "azure_svc_blobstorage", - "bytes", - "futures", - "serde", - "serde_derive", - "serde_json", - "time", - "tracing", - "url", - "uuid", -] - -[[package]] -name = "azure_svc_blobstorage" -version = "0.19.0" -source = "git+https://github.com/omnect/azure-sdk-for-rust.git#ccb2b176813c9b6ff4ff1179ae9419fb3327036d" -dependencies = [ - "azure_core", - "bytes", - "futures", - "log", - "once_cell", - "serde", - "serde_json", - "time", -] - -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - [[package]] name = "base64" version = "0.22.1" @@ -556,19 +347,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "blocking" -version = "1.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" -dependencies = [ - "async-channel 2.5.0", - "async-task", - "futures-io", - "futures-lite 2.6.1", - "piper", -] - [[package]] name = "brotli" version = "8.0.2" @@ -713,26 +491,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "const_format" -version = "0.2.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" -dependencies = [ - "const_format_proc_macros", -] - -[[package]] -name = "const_format_proc_macros" -version = "0.2.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" -dependencies = [ - "proc-macro2", - "quote", - "unicode-xid", -] - [[package]] name = "convert_case" version = "0.10.0" @@ -866,7 +624,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", - "serde_core", ] [[package]] @@ -906,6 +663,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -940,12 +698,6 @@ dependencies = [ "syn", ] -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - [[package]] name = "encoding_rs" version = "0.8.35" @@ -987,15 +739,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] -[[package]] -name = "event-listener" -version = "2.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" - [[package]] name = "event-listener" version = "5.4.1" @@ -1013,19 +759,10 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "event-listener 5.4.1", + "event-listener", "pin-project-lite", ] -[[package]] -name = "fastrand" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" -dependencies = [ - "instant", -] - [[package]] name = "fastrand" version = "2.3.0" @@ -1118,21 +855,6 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" -[[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - [[package]] name = "futures-channel" version = "0.3.32" @@ -1140,7 +862,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", - "futures-sink", ] [[package]] @@ -1149,51 +870,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-lite" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" -dependencies = [ - "fastrand 1.9.0", - "futures-core", - "futures-io", - "memchr", - "parking", - "pin-project-lite", - "waker-fn", -] - -[[package]] -name = "futures-lite" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" -dependencies = [ - "fastrand 2.3.0", - "futures-core", - "futures-io", - "parking", - "pin-project-lite", -] - [[package]] name = "futures-macro" version = "0.3.32" @@ -1229,13 +905,9 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ - "futures-channel", "futures-core", - "futures-io", "futures-macro", - "futures-sink", "futures-task", - "memchr", "pin-project-lite", "slab", ] @@ -1250,17 +922,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "getrandom" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.9.0+wasi-snapshot-preview1", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -1270,7 +931,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "wasm-bindgen", ] @@ -1299,18 +960,6 @@ dependencies = [ "wasip3", ] -[[package]] -name = "getset" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" -dependencies = [ - "proc-macro-error2", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "gptman" version = "1.1.4" @@ -1382,7 +1031,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "headers-core", "http 1.4.0", @@ -1412,6 +1061,15 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "0.2.12" @@ -1456,26 +1114,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "http-types" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e9b187a72d63adbfba487f48095306ac823049cb504ee195541e91c7775f5ad" -dependencies = [ - "anyhow", - "async-channel 1.9.0", - "base64 0.13.1", - "futures-lite 1.13.0", - "infer", - "pin-project-lite", - "rand 0.7.3", - "serde", - "serde_json", - "serde_qs", - "serde_urlencoded", - "url", -] - [[package]] name = "httparse" version = "1.10.1" @@ -1497,7 +1135,7 @@ dependencies = [ "assert-json-diff", "async-object-pool", "async-trait", - "base64 0.22.1", + "base64", "bytes", "crossbeam-utils", "form_urlencoded", @@ -1567,7 +1205,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-channel", "futures-util", @@ -1740,21 +1378,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "infer" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64e9829a50b42bb782c1df523f78d332fe371b10c661e78b7a3c34b0198e9fac" - -[[package]] -name = "instant" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" -dependencies = [ - "cfg-if", -] - [[package]] name = "ipnet" version = "2.12.0" @@ -1975,7 +1598,7 @@ checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "log", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "windows-sys 0.61.2", ] @@ -2021,32 +1644,13 @@ dependencies = [ "libc", ] -[[package]] -name = "oauth2" -version = "4.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c38841cdd844847e3e7c8d29cef9dcfed8877f8f56f9071f77843ecf3baf937f" -dependencies = [ - "base64 0.13.1", - "chrono", - "getrandom 0.2.17", - "http 0.2.12", - "rand 0.8.5", - "serde", - "serde_json", - "serde_path_to_error", - "sha2", - "thiserror 1.0.69", - "url", -] - [[package]] name = "oauth2" version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.22.1", + "base64", "chrono", "getrandom 0.2.17", "http 1.4.0", @@ -2062,18 +1666,13 @@ dependencies = [ [[package]] name = "omnect-cli" -version = "0.27.2" +version = "0.28.0" dependencies = [ "actix-web", "anyhow", "assert-json-diff", "assert_cmd", - "azure_core", - "azure_identity", - "azure_iot_deviceupdate", - "azure_storage", - "azure_storage_blobs", - "base64 0.22.1", + "base64", "bzip2", "clap", "data-encoding", @@ -2083,13 +1682,14 @@ dependencies = [ "filemagic", "flate2", "gptman", + "hmac", "httpmock", "keyring", "libfs", "log", "mbrman", "num_cpus", - "oauth2 5.0.0", + "oauth2", "omnect-crypto", "open", "regex", @@ -2220,12 +1820,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - [[package]] name = "path-tree" version = "0.8.3" @@ -2247,26 +1841,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "pin-project" -version = "1.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2279,17 +1853,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" -[[package]] -name = "piper" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" -dependencies = [ - "atomic-waker", - "fastrand 2.3.0", - "futures-io", -] - [[package]] name = "pkg-config" version = "0.3.32" @@ -2302,20 +1865,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" -[[package]] -name = "polling" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" -dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix", - "windows-sys 0.61.2", -] - [[package]] name = "potential_utf" version = "0.1.4" @@ -2408,16 +1957,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "quick-xml" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "quote" version = "1.0.45" @@ -2445,19 +1984,6 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" -[[package]] -name = "rand" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" -dependencies = [ - "getrandom 0.1.16", - "libc", - "rand_chacha 0.2.2", - "rand_core 0.5.1", - "rand_hc", -] - [[package]] name = "rand" version = "0.8.5" @@ -2479,16 +2005,6 @@ dependencies = [ "rand_core 0.9.5", ] -[[package]] -name = "rand_chacha" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" -dependencies = [ - "ppv-lite86", - "rand_core 0.5.1", -] - [[package]] name = "rand_chacha" version = "0.3.1" @@ -2509,15 +2025,6 @@ dependencies = [ "rand_core 0.9.5", ] -[[package]] -name = "rand_core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" -dependencies = [ - "getrandom 0.1.16", -] - [[package]] name = "rand_core" version = "0.6.4" @@ -2536,15 +2043,6 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "rand_hc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" -dependencies = [ - "rand_core 0.5.1", -] - [[package]] name = "redox_syscall" version = "0.5.18" @@ -2615,23 +2113,27 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-core", "http 1.4.0", "http-body", "http-body-util", "hyper", + "hyper-tls", "hyper-util", "js-sys", "log", + "native-tls", "percent-encoding", "pin-project-lite", + "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tower", "tower-http", "tower-service", @@ -2647,7 +2149,7 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-core", "http 1.4.0", @@ -2709,7 +2211,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2840,17 +2342,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "serde_qs" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7715380eec75f029a4ef7de39a9200e0a63823176b759d055b613f5a87df6a6" -dependencies = [ - "percent-encoding", - "serde", - "thiserror 1.0.69", -] - [[package]] name = "serde_regex" version = "1.1.0" @@ -3006,6 +2497,12 @@ dependencies = [ "syn", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -3054,9 +2551,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "tar" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" dependencies = [ "filetime", "libc", @@ -3069,11 +2566,11 @@ version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ - "fastrand 2.3.0", - "getrandom 0.3.4", + "fastrand", + "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3130,7 +2627,6 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", - "js-sys", "num-conv", "powerfmt", "serde_core", @@ -3391,9 +2887,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" dependencies = [ "getrandom 0.4.2", - "js-sys", - "serde_core", - "wasm-bindgen", ] [[package]] @@ -3447,12 +2940,6 @@ dependencies = [ "libc", ] -[[package]] -name = "waker-fn" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" - [[package]] name = "want" version = "0.3.1" @@ -3462,12 +2949,6 @@ dependencies = [ "try-lock", ] -[[package]] -name = "wasi" -version = "0.9.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/Cargo.toml b/Cargo.toml index 23e5655..aec7e3c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,24 +7,11 @@ license = "MIT OR Apache-2.0" name = "omnect-cli" readme = "README.md" repository = "https://github.com/omnect/omnect-cli" -version = "0.27.2" +version = "0.28.0" [dependencies] actix-web = "4.11" anyhow = "1" -# switch back to https://github.com/Azure/azure-sdk-for-rust.git as soon as -# https://github.com/Azure/azure-sdk-for-rust/pull/1636 -# is merged and a new new release is available -#azure_core = { git = "https://github.com/Azure/azure-sdk-for-rust.git", tag = "v2024-??-??" } -#azure_iot_deviceupdate = { git = "https://github.com/Azure/azure-sdk-for-rust.git", tag = "v2024-??-??" } -#azure_identity = { git = "https://github.com/Azure/azure-sdk-for-rust.git", tag = "v2024-??-??" } -#azure_storage = { git = "https://github.com/Azure/azure-sdk-for-rust.git", tag = "v2024-??-??" } -#azure_storage_blobs = { git = "https://github.com/Azure/azure-sdk-for-rust.git", tag = "v2024-??-??" } -azure_core = { git = "https://github.com/omnect/azure-sdk-for-rust.git", default-features = false } -azure_iot_deviceupdate = { git = "https://github.com/omnect/azure-sdk-for-rust.git", default-features = false } -azure_identity = { git = "https://github.com/omnect/azure-sdk-for-rust.git", default-features = false } -azure_storage = { git = "https://github.com/omnect/azure-sdk-for-rust.git", default-features = false } -azure_storage_blobs = { git = "https://github.com/omnect/azure-sdk-for-rust.git", default-features = false } base64 = { version = "0.22", default-features = false } bzip2 = { version = "0.6", default-features = true } clap = { version = "4.5", default-features = false, features = [ @@ -38,13 +25,17 @@ filemagic = { version = "0.13", default-features = false, features = [ ] } flate2 = { version = "1.1", default-features = false } gptman = { version = "1.1", default-features = false } +hmac = { version = "0.12", default-features = false } mbrman = { version = "0.5", default-features = false } omnect-crypto = { git = "https://github.com/omnect/omnect-crypto.git", tag = "0.4.0" } keyring = { version = "3.6", default-features = false } libfs = { version = "0.9", default-features = false } log = { version = "0.4", default-features = false } num_cpus = { version = "1.17", default-features = false } -oauth2 = { version = "5.0", default-features = false, features = ["reqwest"] } +oauth2 = { version = "5.0", default-features = false, features = [ + "reqwest", + "native-tls", +] } open = { version = "5.3", default-features = false } regex = { version = "1.11", default-features = false } reqwest = { version = "0.13", default-features = false, features = [ diff --git a/src/device_update/blob_sas.rs b/src/device_update/blob_sas.rs new file mode 100644 index 0000000..8ae2bfd --- /dev/null +++ b/src/device_update/blob_sas.rs @@ -0,0 +1,128 @@ +use anyhow::{Context, Result}; +use base64::prelude::*; +use hmac::{Hmac, Mac}; +use sha2::Sha256; +use time::OffsetDateTime; +use url::Url; +use url::form_urlencoded; + +const SAS_VERSION: &str = "2022-11-02"; + +/// Generates a Service SAS URL for a blob with the given permissions and expiry. +/// +/// Uses HMAC-SHA256 signing per the Azure Storage Service SAS specification. +/// Reference: https://learn.microsoft.com/en-us/rest/api/storageservices/create-service-sas +pub(crate) fn generate_blob_sas_url( + account: &str, + account_key: &str, + container: &str, + blob: &str, + permissions: &str, + expiry: OffsetDateTime, +) -> Result { + let expiry_str = format_sas_time(expiry); + let canonicalized_resource = format!("/blob/{account}/{container}/{blob}"); + + // StringToSign for version 2020-12-06 and later (16 fields, 15 newlines): + // signedPermissions, signedStart, signedExpiry, canonicalizedResource, + // signedIdentifier, signedIP, signedProtocol, signedVersion, + // signedResource, signedSnapshotTime, signedEncryptionScope, + // rscc, rscd, rsce, rscl, rsct + let string_to_sign = format!( + "{permissions}\n\ + \n\ + {expiry_str}\n\ + {canonicalized_resource}\n\ + \n\ + \n\ + https\n\ + {SAS_VERSION}\n\ + b\n\ + \n\ + \n\ + \n\ + \n\ + \n\ + \n" + ); + + let signature = sign_hmac_sha256(account_key, &string_to_sign)?; + + let sas_query: String = form_urlencoded::Serializer::new(String::new()) + .append_pair("sp", permissions) + .append_pair("se", &expiry_str) + .append_pair("spr", "https") + .append_pair("sv", SAS_VERSION) + .append_pair("sr", "b") + .append_pair("sig", &signature) + .finish(); + + let url_str = format!("https://{account}.blob.core.windows.net/{container}/{blob}?{sas_query}"); + + Url::parse(&url_str).context("failed to construct SAS URL") +} + +fn sign_hmac_sha256(account_key_base64: &str, message: &str) -> Result { + let key_bytes = BASE64_STANDARD + .decode(account_key_base64) + .context("invalid base64 storage account key")?; + + let mut mac = Hmac::::new_from_slice(&key_bytes).context("invalid HMAC key length")?; + + mac.update(message.as_bytes()); + + Ok(BASE64_STANDARD.encode(mac.finalize().into_bytes())) +} + +fn format_sas_time(dt: OffsetDateTime) -> String { + format!( + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", + dt.year(), + dt.month() as u8, + dt.day(), + dt.hour(), + dt.minute(), + dt.second() + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_sas_time_produces_iso8601_utc() { + let dt = OffsetDateTime::from_unix_timestamp(1684893235).expect("valid timestamp"); + assert_eq!(format_sas_time(dt), "2023-05-24T01:53:55Z"); + } + + #[test] + fn sign_hmac_sha256_produces_deterministic_output() { + // base64("testkey1") = "dGVzdGtleTEK" — but we need a real base64 key + let key = BASE64_STANDARD.encode(b"test-storage-key"); + let sig1 = sign_hmac_sha256(&key, "test message").expect("sign works"); + let sig2 = sign_hmac_sha256(&key, "test message").expect("sign works"); + assert_eq!(sig1, sig2); + assert!(!sig1.is_empty()); + } + + #[test] + fn generate_blob_sas_url_produces_valid_url() { + let key = BASE64_STANDARD.encode(b"fake-storage-account-key-for-testing"); + let expiry = OffsetDateTime::from_unix_timestamp(1716544435).expect("valid timestamp"); + + let url = + generate_blob_sas_url("myaccount", &key, "mycontainer", "myblob.txt", "r", expiry) + .expect("SAS URL generation works"); + + assert!( + url.as_str() + .starts_with("https://myaccount.blob.core.windows.net/mycontainer/myblob.txt?") + ); + assert!(url.as_str().contains("sp=r")); + assert!(url.as_str().contains("sr=b")); + assert!(url.as_str().contains("sv=2022-11-02")); + assert!(url.as_str().contains("sig=")); + assert!(url.as_str().contains("spr=https")); + } +} diff --git a/src/device_update/client.rs b/src/device_update/client.rs new file mode 100644 index 0000000..f5e6e3c --- /dev/null +++ b/src/device_update/client.rs @@ -0,0 +1,230 @@ +use anyhow::{Context, Result, bail}; +use log::{debug, info, warn}; +use serde::Deserialize; +use url::Url; + +use crate::device_update::token::AzureTokenProvider; + +const API_VERSION: &str = "2022-10-01"; +const INITIAL_POLL_INTERVAL_SECS: u64 = 2; +const MAX_POLL_INTERVAL_SECS: u64 = 30; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UpdateOperation { + #[serde(rename = "operationId")] + pub operation_id: String, + pub status: OperationStatus, + pub error: Option, +} + +#[derive(Debug, Deserialize, PartialEq)] +#[serde(rename_all = "PascalCase")] +pub(crate) enum OperationStatus { + NotStarted, + Running, + Succeeded, + Failed, + #[serde(other)] + Unknown, +} + +impl std::fmt::Display for OperationStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotStarted => write!(f, "NotStarted"), + Self::Running => write!(f, "Running"), + Self::Succeeded => write!(f, "Succeeded"), + Self::Failed => write!(f, "Failed"), + Self::Unknown => write!(f, "Unknown"), + } + } +} + +pub(crate) struct DeviceUpdateClient { + endpoint: Url, + http_client: reqwest::Client, + token_provider: AzureTokenProvider, +} + +impl DeviceUpdateClient { + pub(crate) fn new( + endpoint: &str, + tenant_id: &str, + client_id: &str, + client_secret: &str, + ) -> Result { + let endpoint = Url::parse(endpoint).context("invalid device update endpoint URL")?; + let token_provider = AzureTokenProvider::new(tenant_id, client_id, client_secret)?; + let http_client = reqwest::Client::new(); + + Ok(Self { + endpoint, + http_client, + token_provider, + }) + } + + pub(crate) async fn import_update( + &self, + instance_id: &str, + import_json: String, + ) -> Result { + let url = self + .endpoint + .join(&format!( + "/deviceupdate/{instance_id}/updates:import?api-version={API_VERSION}" + )) + .context("failed to construct import URL")?; + + debug!("POST {url}"); + + let token = self.token_provider.get_token().await?; + + let response = self + .http_client + .post(url.as_str()) + .bearer_auth(&token) + .header("content-type", "application/json") + .body(import_json) + .send() + .await + .context("import update request failed")?; + + self.handle_async_response(response, "import update").await + } + + pub(crate) async fn delete_update( + &self, + instance_id: &str, + provider: &str, + name: &str, + version: &str, + ) -> Result { + let url = self + .endpoint + .join(&format!( + "/deviceupdate/{instance_id}/updates/providers/{provider}/names/{name}/versions/{version}?api-version={API_VERSION}" + )) + .context("failed to construct delete URL")?; + + debug!("DELETE {url}"); + + let token = self.token_provider.get_token().await?; + + let response = self + .http_client + .delete(url.as_str()) + .bearer_auth(&token) + .send() + .await + .context("delete update request failed")?; + + self.handle_async_response(response, "delete update").await + } + + async fn handle_async_response( + &self, + response: reqwest::Response, + operation_name: &str, + ) -> Result { + let status = response.status(); + + match status.as_u16() { + 200 => { + let body = response + .text() + .await + .context("failed to read response body")?; + serde_json::from_str(&body) + .context("failed to parse immediate response as UpdateOperation") + } + 202 => { + let operation_location = response + .headers() + .get("operation-location") + .context("202 response missing operation-location header")? + .to_str() + .context("operation-location header is not valid UTF-8")? + .to_string(); + + info!("{operation_name}: accepted, polling {operation_location}"); + + self.poll_operation(&operation_location).await + } + _ => { + let body = response.text().await.unwrap_or_default(); + bail!("{operation_name} failed with status {status}: {body}"); + } + } + } + + async fn poll_operation(&self, operation_url: &str) -> Result { + let poll_url = if operation_url.starts_with("http") { + Url::parse(operation_url).context("invalid operation-location URL")? + } else { + self.endpoint + .join(operation_url) + .context("failed to resolve relative operation-location URL")? + }; + + let mut interval_secs = INITIAL_POLL_INTERVAL_SECS; + + loop { + tokio::time::sleep(std::time::Duration::from_secs(interval_secs)).await; + + let token = self.token_provider.get_token().await?; + + let response = self + .http_client + .get(poll_url.as_str()) + .bearer_auth(&token) + .send() + .await + .context("failed to poll operation status")?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + bail!("operation status poll failed with status {status}: {body}"); + } + + let body = response + .text() + .await + .context("failed to read operation status response")?; + + let operation: UpdateOperation = + serde_json::from_str(&body).context("failed to parse operation status response")?; + + debug!( + "operation {} status: {}", + operation.operation_id, operation.status + ); + + match operation.status { + OperationStatus::Succeeded => return Ok(operation), + OperationStatus::Failed => { + let error_detail = operation + .error + .as_ref() + .map(|e| e.to_string()) + .unwrap_or_else(|| "no error details".to_string()); + bail!( + "operation {} failed: {error_detail}", + operation.operation_id + ); + } + OperationStatus::Unknown => { + warn!( + "operation {} returned unknown status, continuing to poll", + operation.operation_id + ); + } + _ => {} + } + + interval_secs = (interval_secs * 2).min(MAX_POLL_INTERVAL_SECS); + } + } +} diff --git a/src/device_update.rs b/src/device_update/mod.rs similarity index 76% rename from src/device_update.rs rename to src/device_update/mod.rs index 9a50b7a..8e2f6da 100644 --- a/src/device_update.rs +++ b/src/device_update/mod.rs @@ -1,8 +1,8 @@ +mod blob_sas; +mod client; +mod token; + use anyhow::{Context, Result}; -use azure_identity::{ClientSecretCredential, TokenCredentialOptions}; -use azure_iot_deviceupdate::DeviceUpdateClient; -use azure_storage::{StorageCredentials, shared_access_signature::service_sas::BlobSasPermissions}; -use azure_storage_blobs::prelude::{BlobServiceClient, ContainerClient}; use base64::prelude::*; use log::{debug, info}; use serde::Serialize; @@ -199,24 +199,23 @@ pub async fn import_update( blob_storage_account: &str, blob_storage_key: &str, ) -> Result<()> { - let creds = std::sync::Arc::new(ClientSecretCredential::new( - azure_core::new_http_client(), - TokenCredentialOptions::default().authority_host()?, - tenant_id.to_string(), - client_id.to_string(), - client_secret.to_string(), - )); - let client = DeviceUpdateClient::new(device_update_endpoint_url.as_str(), creds)?; + let adu_client = client::DeviceUpdateClient::new( + device_update_endpoint_url.as_str(), + tenant_id, + client_id, + client_secret, + )?; + let manifest_file_size = std::fs::metadata(import_manifest_path) .context(format!( "cannot get file metadata of {}", import_manifest_path .to_str() - .context("import manifest pah invalid")? + .context("import manifest path invalid")? ))? .len(); let manifest_sha256 = BASE64_STANDARD.encode(sha2::Sha256::digest( - std::fs::read(import_manifest_path).unwrap(), + std::fs::read(import_manifest_path).context("cannot read import manifest")?, )); let manifest: serde_json::Value = serde_json::from_reader( @@ -237,14 +236,38 @@ pub async fn import_update( .context("step2 file not found")? .to_string(); - let storage_credentials = - StorageCredentials::access_key(blob_storage_account, blob_storage_key.to_string()); - let storage_account_client = BlobServiceClient::new(blob_storage_account, storage_credentials); - let container_client = storage_account_client.container_client(container_name); - let import_manifest_path = import_manifest_path.file_name().unwrap().to_str().unwrap(); - let manifest_url = generate_sas_url(&container_client, import_manifest_path).await?; - let file_url1 = generate_sas_url(&container_client, file_name1.clone()).await?; - let file_url2 = generate_sas_url(&container_client, file_name2.clone()).await?; + let sas_expiry = time::OffsetDateTime::now_utc() + time::Duration::hours(12); + let import_manifest_filename = import_manifest_path + .file_name() + .context("import manifest has no filename")? + .to_str() + .context("import manifest filename is not valid UTF-8")?; + + let manifest_url = blob_sas::generate_blob_sas_url( + blob_storage_account, + blob_storage_key, + container_name, + import_manifest_filename, + "r", + sas_expiry, + )?; + let file_url1 = blob_sas::generate_blob_sas_url( + blob_storage_account, + blob_storage_key, + container_name, + &file_name1, + "r", + sas_expiry, + )?; + let file_url2 = blob_sas::generate_blob_sas_url( + blob_storage_account, + blob_storage_key, + container_name, + &file_name2, + "r", + sas_expiry, + )?; + let import_update = vec![ImportUpdate { import_manifest: FileUrl { url: manifest_url, @@ -264,11 +287,11 @@ pub async fn import_update( }]; let import_update = - serde_json::to_string_pretty(&import_update).context("Cannot parse import_update")?; + serde_json::to_string_pretty(&import_update).context("cannot serialize import_update")?; debug!("import update: {import_update}"); - let import_update_response = client.import_update(instance_id, import_update).await?; + let import_update_response = adu_client.import_update(instance_id, import_update).await?; info!("Result of import update: {:?}", &import_update_response); Ok(()) @@ -286,18 +309,16 @@ pub async fn remove_update( name: &str, version: &str, ) -> Result<()> { - let creds = std::sync::Arc::new(ClientSecretCredential::new( - azure_core::new_http_client(), - TokenCredentialOptions::default().authority_host()?, - tenant_id.to_string(), - client_id.to_string(), - client_secret.to_string(), - )); - let client = DeviceUpdateClient::new(device_update_endpoint_url.as_str(), creds)?; + let adu_client = client::DeviceUpdateClient::new( + device_update_endpoint_url.as_str(), + tenant_id, + client_id, + client_secret, + )?; debug!("remove update"); - let remove_update_response = client + let remove_update_response = adu_client .delete_update(instance_id, provider, name, version) .await?; info!("Result of remove update: {:?}", &remove_update_response); @@ -335,24 +356,3 @@ fn get_file_attributes(file: &Path) -> Result> { hashes, }) } - -pub async fn generate_sas_url( - container_client: &ContainerClient, - blob_name: impl Into, -) -> Result { - let blob_client = container_client.blob_client(blob_name); - - let token = blob_client - .shared_access_signature( - BlobSasPermissions { - read: true, - ..BlobSasPermissions::default() - }, - time::OffsetDateTime::now_utc() + time::Duration::hours(12), - ) - .await?; - - blob_client - .generate_signed_blob_url(&token) - .map_err(|e| e.into()) -} diff --git a/src/device_update/token.rs b/src/device_update/token.rs new file mode 100644 index 0000000..b88913e --- /dev/null +++ b/src/device_update/token.rs @@ -0,0 +1,89 @@ +use anyhow::{Context, Result}; +use log::debug; +use oauth2::{ClientId, ClientSecret, Scope, TokenResponse, TokenUrl}; +use tokio::sync::Mutex; + +const AZURE_AD_TOKEN_URL_PREFIX: &str = "https://login.microsoftonline.com/"; +const AZURE_AD_TOKEN_URL_SUFFIX: &str = "/oauth2/v2.0/token"; +const ADU_API_SCOPE: &str = "https://api.adu.microsoft.com/.default"; +// Refresh the token 60 seconds before actual expiry to avoid races +const TOKEN_EXPIRY_MARGIN_SECS: u64 = 60; + +struct CachedToken { + access_token: String, + expires_at: std::time::Instant, +} + +pub(crate) struct AzureTokenProvider { + client_id: ClientId, + client_secret: ClientSecret, + token_url: TokenUrl, + http_client: oauth2::reqwest::Client, + scope: Scope, + cached: Mutex>, +} + +impl AzureTokenProvider { + pub(crate) fn new(tenant_id: &str, client_id: &str, client_secret: &str) -> Result { + let token_url_str = + format!("{AZURE_AD_TOKEN_URL_PREFIX}{tenant_id}{AZURE_AD_TOKEN_URL_SUFFIX}"); + + let http_client = oauth2::reqwest::ClientBuilder::new() + .redirect(oauth2::reqwest::redirect::Policy::none()) + .build() + .context("failed to create HTTP client for token provider")?; + + Ok(Self { + client_id: ClientId::new(client_id.to_string()), + client_secret: ClientSecret::new(client_secret.to_string()), + token_url: TokenUrl::new(token_url_str).context("invalid Azure AD token URL")?, + http_client, + scope: Scope::new(ADU_API_SCOPE.to_string()), + cached: Mutex::new(None), + }) + } + + pub(crate) async fn get_token(&self) -> Result { + let mut cached = self.cached.lock().await; + + if let Some(ref token) = *cached { + if token.expires_at > std::time::Instant::now() { + debug!("using cached Azure AD token"); + return Ok(token.access_token.clone()); + } + debug!("cached Azure AD token expired, refreshing"); + } + + let client = oauth2::basic::BasicClient::new(self.client_id.clone()) + .set_client_secret(self.client_secret.clone()) + .set_token_uri(self.token_url.clone()); + + let response = client + .exchange_client_credentials() + .add_scope(self.scope.clone()) + .request_async(&self.http_client) + .await + .context("failed to acquire Azure AD token via client credentials")?; + + let access_token = response.access_token().secret().to_string(); + + let expires_in = response + .expires_in() + .unwrap_or(std::time::Duration::from_secs(3600)); + + let expires_at = std::time::Instant::now() + + expires_in.saturating_sub(std::time::Duration::from_secs(TOKEN_EXPIRY_MARGIN_SECS)); + + debug!( + "acquired Azure AD token, expires in {}s", + expires_in.as_secs() + ); + + *cached = Some(CachedToken { + access_token: access_token.clone(), + expires_at, + }); + + Ok(access_token) + } +} diff --git a/src/main.rs b/src/main.rs index 0b741bf..8aa9207 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,28 +5,15 @@ use std::process; fn main() { // storage_account_client logs cleartext credentials, the others are just unnecessarily verbose. if cfg!(debug_assertions) { - Builder::from_env(Env::default().default_filter_or(concat!( - "debug", - ",azure_core::http_client::reqwest=debug", - ",azure_core::policies::transport=debug", - ",azure_iot_deviceupdate::device_update=debug", - ",azure_storage::core::clients::storage_account_client=info", - ",azure_storage_blobs=info", - ",device_update_importer::blob_uploader=info", - ",reqwest::async_impl::client=debug" - ))) + Builder::from_env( + Env::default() + .default_filter_or(concat!("debug", ",reqwest::async_impl::client=debug")), + ) .init(); } else { - Builder::from_env(Env::default().default_filter_or(concat!( - "info", - ",azure_core::http_client::reqwest=debug", - ",azure_core::policies::transport=debug", - ",azure_iot_deviceupdate::device_update=debug", - ",azure_storage::core::clients::storage_account_client=info", - ",azure_storage_blobs=info", - ",device_update_importer::blob_uploader=info", - ",reqwest::async_impl::client=debug" - ))) + Builder::from_env( + Env::default().default_filter_or(concat!("info", ",reqwest::async_impl::client=debug")), + ) .init(); } From 11e0a86b6bb027f51508e810e9655091b31d31a8 Mon Sep 17 00:00:00 2001 From: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:37:56 +0200 Subject: [PATCH 3/3] fix(device_update): address PR #155 review feedback - Remove .gitignore self-referencing entry - Add 10-minute timeout to operation polling loop - Add 30s request timeout to reqwest::Client - Add 30s timeout to OAuth2 token HTTP client - Remove stale comment about storage_account_client in main.rs - Fix SAS version comment to reference sv=2022-11-02 Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com> --- .gitignore | 1 - src/device_update/blob_sas.rs | 2 +- src/device_update/client.rs | 15 ++++++++++++++- src/device_update/token.rs | 2 ++ src/main.rs | 1 - 5 files changed, 17 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 1ab0d5e..7462742 100644 --- a/.gitignore +++ b/.gitignore @@ -10,5 +10,4 @@ docker-run.sh setup-ai.sh .user-context.md CLAUDE.md -.gitignore GEMINI.md diff --git a/src/device_update/blob_sas.rs b/src/device_update/blob_sas.rs index 8ae2bfd..a565608 100644 --- a/src/device_update/blob_sas.rs +++ b/src/device_update/blob_sas.rs @@ -23,7 +23,7 @@ pub(crate) fn generate_blob_sas_url( let expiry_str = format_sas_time(expiry); let canonicalized_resource = format!("/blob/{account}/{container}/{blob}"); - // StringToSign for version 2020-12-06 and later (16 fields, 15 newlines): + // StringToSign for sv=2022-11-02 (16 fields, 15 newlines): // signedPermissions, signedStart, signedExpiry, canonicalizedResource, // signedIdentifier, signedIP, signedProtocol, signedVersion, // signedResource, signedSnapshotTime, signedEncryptionScope, diff --git a/src/device_update/client.rs b/src/device_update/client.rs index f5e6e3c..eb07985 100644 --- a/src/device_update/client.rs +++ b/src/device_update/client.rs @@ -8,6 +8,8 @@ use crate::device_update::token::AzureTokenProvider; const API_VERSION: &str = "2022-10-01"; const INITIAL_POLL_INTERVAL_SECS: u64 = 2; const MAX_POLL_INTERVAL_SECS: u64 = 30; +const HTTP_REQUEST_TIMEOUT_SECS: u64 = 30; +const POLL_TIMEOUT_SECS: u64 = 600; // 10 minutes #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -56,7 +58,10 @@ impl DeviceUpdateClient { ) -> Result { let endpoint = Url::parse(endpoint).context("invalid device update endpoint URL")?; let token_provider = AzureTokenProvider::new(tenant_id, client_id, client_secret)?; - let http_client = reqwest::Client::new(); + let http_client = reqwest::ClientBuilder::new() + .timeout(std::time::Duration::from_secs(HTTP_REQUEST_TIMEOUT_SECS)) + .build() + .context("failed to create HTTP client")?; Ok(Self { endpoint, @@ -160,6 +165,14 @@ impl DeviceUpdateClient { } async fn poll_operation(&self, operation_url: &str) -> Result { + let timeout = std::time::Duration::from_secs(POLL_TIMEOUT_SECS); + + tokio::time::timeout(timeout, self.poll_operation_inner(operation_url)) + .await + .context("operation polling timed out")? + } + + async fn poll_operation_inner(&self, operation_url: &str) -> Result { let poll_url = if operation_url.starts_with("http") { Url::parse(operation_url).context("invalid operation-location URL")? } else { diff --git a/src/device_update/token.rs b/src/device_update/token.rs index b88913e..5d7a601 100644 --- a/src/device_update/token.rs +++ b/src/device_update/token.rs @@ -8,6 +8,7 @@ const AZURE_AD_TOKEN_URL_SUFFIX: &str = "/oauth2/v2.0/token"; const ADU_API_SCOPE: &str = "https://api.adu.microsoft.com/.default"; // Refresh the token 60 seconds before actual expiry to avoid races const TOKEN_EXPIRY_MARGIN_SECS: u64 = 60; +const TOKEN_REQUEST_TIMEOUT_SECS: u64 = 30; struct CachedToken { access_token: String, @@ -30,6 +31,7 @@ impl AzureTokenProvider { let http_client = oauth2::reqwest::ClientBuilder::new() .redirect(oauth2::reqwest::redirect::Policy::none()) + .timeout(std::time::Duration::from_secs(TOKEN_REQUEST_TIMEOUT_SECS)) .build() .context("failed to create HTTP client for token provider")?; diff --git a/src/main.rs b/src/main.rs index 8aa9207..207a709 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,7 +3,6 @@ use log::{error, info}; use std::process; fn main() { - // storage_account_client logs cleartext credentials, the others are just unnecessarily verbose. if cfg!(debug_assertions) { Builder::from_env( Env::default()