diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 9426f088..e4550840 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -7,7 +7,6 @@ ':semanticCommits', ':enablePreCommit', ':automergeDigest', - ':automergeBranch', 'helpers:pinGitHubActionDigests', ], dependencyDashboardLabels: [ @@ -54,7 +53,7 @@ 'patch', ], automerge: true, - automergeType: 'branch', + automergeType: 'pr', }, { description: 'Auto merge warpgate patch and minor updates', diff --git a/.github/workflows/build-and-push-templates.yaml b/.github/workflows/build-and-push-templates.yaml index 20ed2805..41b276b8 100644 --- a/.github/workflows/build-and-push-templates.yaml +++ b/.github/workflows/build-and-push-templates.yaml @@ -8,6 +8,15 @@ on: - 'warpgate-templates/**' - 'ansible/**' - '.github/workflows/build-and-push-templates.yaml' + # Template images bake the Rust `ares` binary from these crates; + # rebuild when their source changes too. + - 'ares-cli/**' + - 'ares-core/**' + - 'ares-llm/**' + - 'ares-rust/**' + - 'ares-tools/**' + - 'Cargo.toml' + - 'Cargo.lock' workflow_dispatch: inputs: template_filter: @@ -508,7 +517,7 @@ jobs: fi - name: Login to GitHub Container Registry (Docker) - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 with: registry: ghcr.io username: ${{ github.actor }} @@ -638,7 +647,7 @@ jobs: cat ~/.config/warpgate/config.yaml - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4 - name: Register templates with Warpgate run: | @@ -872,14 +881,14 @@ jobs: done - name: Login to GitHub Container Registry - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4 with: driver: docker-container @@ -999,7 +1008,7 @@ jobs: fi - name: Login to GitHub Container Registry (Docker) - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 with: registry: ghcr.io username: ${{ github.actor }} @@ -1129,7 +1138,7 @@ jobs: cat ~/.config/warpgate/config.yaml - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4 - name: Register templates with Warpgate run: | @@ -1367,14 +1376,14 @@ jobs: done - name: Login to GitHub Container Registry - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4 with: driver: docker-container @@ -1473,7 +1482,7 @@ jobs: fi - name: Login to GitHub Container Registry (Docker) - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 with: registry: ghcr.io username: ${{ github.actor }} @@ -1562,7 +1571,7 @@ jobs: EOF - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4 - name: Register templates with Warpgate run: | @@ -1706,14 +1715,14 @@ jobs: done - name: Login to GitHub Container Registry - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4 with: driver: docker-container @@ -1808,7 +1817,7 @@ jobs: fi - name: Login to GitHub Container Registry (Docker) - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 with: registry: ghcr.io username: ${{ github.actor }} @@ -1897,7 +1906,7 @@ jobs: EOF - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4 - name: Register templates with Warpgate run: | @@ -2045,14 +2054,14 @@ jobs: done - name: Login to GitHub Container Registry - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4 with: driver: docker-container diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 4996ac6a..0f3f5c09 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -86,7 +86,7 @@ jobs: done - name: Upload artifacts - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: binaries-${{ matrix.target }} path: | diff --git a/.github/workflows/renovate.yaml b/.github/workflows/renovate.yaml index 1a375b8c..c27febed 100644 --- a/.github/workflows/renovate.yaml +++ b/.github/workflows/renovate.yaml @@ -77,6 +77,8 @@ jobs: RENOVATE_AUTODISCOVER: true RENOVATE_AUTODISCOVER_FILTER: "${{ github.repository }}" RENOVATE_DRY_RUN: "${{ inputs.dryRun }}" + # Required: renovate refuses to process forks unless explicitly enabled. + RENOVATE_FORK_PROCESSING: enabled RENOVATE_INTERNAL_CHECKS_FILTER: strict RENOVATE_PLATFORM: github RENOVATE_PLATFORM_COMMIT: true diff --git a/.github/workflows/rust.yaml b/.github/workflows/rust.yaml index 369590b7..c7af1d71 100644 --- a/.github/workflows/rust.yaml +++ b/.github/workflows/rust.yaml @@ -79,7 +79,7 @@ jobs: components: llvm-tools-preview - name: Install cargo-llvm-cov - uses: taiki-e/install-action@735e5933943122c5ac182670a935f54a949265c1 # v2 + uses: taiki-e/install-action@f48d2f8ba2b452934c948b7be1a768079c3632ff # v2 with: tool: cargo-llvm-cov @@ -99,7 +99,7 @@ jobs: run: cargo llvm-cov --workspace --lcov --output-path lcov.info - name: Upload coverage to Codecov - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6 + uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6 with: files: lcov.info token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/semgrep.yaml b/.github/workflows/semgrep.yaml index a7189b74..d4d0c5c0 100644 --- a/.github/workflows/semgrep.yaml +++ b/.github/workflows/semgrep.yaml @@ -66,7 +66,7 @@ jobs: - name: Upload SARIF to GitHub Security tab if: always() - uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 + uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 with: sarif_file: semgrep-results.sarif env: diff --git a/.github/workflows/test-template-builds.yaml b/.github/workflows/test-template-builds.yaml index 20a9db10..ad18bad4 100644 --- a/.github/workflows/test-template-builds.yaml +++ b/.github/workflows/test-template-builds.yaml @@ -277,7 +277,7 @@ jobs: fi - name: Login to GitHub Container Registry - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 with: registry: ghcr.io username: ${{ github.actor }} @@ -357,7 +357,7 @@ jobs: EOF - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4 with: driver-opts: | image=moby/buildkit:latest @@ -477,7 +477,7 @@ jobs: fi - name: Login to GitHub Container Registry - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 with: registry: ghcr.io username: ${{ github.actor }} @@ -557,7 +557,7 @@ jobs: EOF - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4 with: driver-opts: | image=moby/buildkit:latest diff --git a/.hooks/requirements.txt b/.hooks/requirements.txt index 5d094727..883c3ea1 100644 --- a/.hooks/requirements.txt +++ b/.hooks/requirements.txt @@ -1,4 +1,4 @@ -ansible-core==2.20.5 +ansible-core==2.21.0 ansible-lint==26.4.0 docker==7.1.0 docsible==0.8.0 diff --git a/Cargo.lock b/Cargo.lock index d82c5b15..3823f135 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -149,7 +149,7 @@ dependencies = [ "bytes", "chrono", "futures", - "md-5 0.11.0", + "md-5", "opentelemetry", "opentelemetry-otlp", "opentelemetry_sdk", @@ -180,7 +180,7 @@ dependencies = [ "async-trait", "chrono", "regex", - "reqwest 0.13.3", + "reqwest", "serde", "serde_json", "tempfile", @@ -204,7 +204,7 @@ dependencies = [ "chrono", "redis", "regex", - "reqwest 0.13.3", + "reqwest", "rstest", "serde", "serde_json", @@ -515,6 +515,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" + [[package]] name = "colorchoice" version = "1.0.5" @@ -683,6 +689,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -815,9 +830,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "const-oid 0.9.6", "crypto-common 0.1.7", - "subtle", ] [[package]] @@ -829,6 +842,7 @@ dependencies = [ "block-buffer 0.12.0", "const-oid 0.10.2", "crypto-common 0.2.1", + "ctutils", ] [[package]] @@ -903,13 +917,12 @@ dependencies = [ [[package]] name = "etcetera" -version = "0.8.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" dependencies = [ "cfg-if", - "home", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -953,9 +966,9 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flume" -version = "0.11.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" dependencies = [ "futures-core", "futures-sink", @@ -974,6 +987,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1211,10 +1230,19 @@ name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -1225,11 +1253,11 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashlink" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.1", ] [[package]] @@ -1316,29 +1344,20 @@ dependencies = [ [[package]] name = "hkdf" -version = "0.12.4" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" dependencies = [ "hmac", ] [[package]] name = "hmac" -version = "0.12.1" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "digest 0.10.7", -] - -[[package]] -name = "home" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" -dependencies = [ - "windows-sys 0.61.2", + "digest 0.11.3", ] [[package]] @@ -1429,7 +1448,6 @@ dependencies = [ "hyper", "hyper-util", "rustls", - "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", @@ -1757,9 +1775,6 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin", -] [[package]] name = "leb128fmt" @@ -1779,18 +1794,6 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" -[[package]] -name = "libredox" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" -dependencies = [ - "bitflags", - "libc", - "plain", - "redox_syscall 0.7.5", -] - [[package]] name = "libsqlite3-sys" version = "0.30.1" @@ -1815,9 +1818,9 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "local-ip-address" -version = "0.6.12" +version = "0.6.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7b0187df4e614e42405b49511b82ff7a1774fbd9a816060ee465067847cac22" +checksum = "aa08fb2b1ec3ea84575e94b489d06d4ce0cbf052d12acd515838f50e3c3d63e3" dependencies = [ "libc", "neli", @@ -1854,16 +1857,6 @@ dependencies = [ "regex-automata", ] -[[package]] -name = "md-5" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" -dependencies = [ - "cfg-if", - "digest 0.10.7", -] - [[package]] name = "md-5" version = "0.11.0" @@ -1986,22 +1979,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-bigint-dig" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" -dependencies = [ - "lazy_static", - "libm", - "num-integer", - "num-iter", - "num-traits", - "rand 0.8.6", - "smallvec", - "zeroize", -] - [[package]] name = "num-conv" version = "0.2.1" @@ -2017,17 +1994,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-iter" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -2035,7 +2001,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", - "libm", ] [[package]] @@ -2062,9 +2027,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "opentelemetry" -version = "0.31.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0" +checksum = "b0142c63252a9e054e68a4c61a5778f7b14f576274d593f8ce883d191a099682" dependencies = [ "futures-core", "futures-sink", @@ -2076,22 +2041,22 @@ dependencies = [ [[package]] name = "opentelemetry-http" -version = "0.31.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" +checksum = "5683015d09e2df236ef005b17f6f196f0d5f6313c4fa43a7b6a53b52776e4331" dependencies = [ "async-trait", "bytes", "http", "opentelemetry", - "reqwest 0.12.28", + "reqwest", ] [[package]] name = "opentelemetry-otlp" -version = "0.31.1" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f69cd6acbb9af919df949cd1ec9e5e7fdc2ef15d234b6b795aaa525cc02f71f" +checksum = "9966929966d17620d7c316c643ba62631826e10021409357772d5eea84f62c35" dependencies = [ "http", "opentelemetry", @@ -2099,18 +2064,18 @@ dependencies = [ "opentelemetry-proto", "opentelemetry_sdk", "prost", - "reqwest 0.12.28", + "reqwest", "thiserror", "tokio", "tonic", - "tracing", + "tonic-types", ] [[package]] name = "opentelemetry-proto" -version = "0.31.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f" +checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638" dependencies = [ "opentelemetry", "opentelemetry_sdk", @@ -2121,15 +2086,16 @@ dependencies = [ [[package]] name = "opentelemetry_sdk" -version = "0.31.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd" +checksum = "368afaed344110f40b179bb8fbe54bc52d98f9bd2b281799ef32487c2650c956" dependencies = [ "futures-channel", "futures-executor", "futures-util", "opentelemetry", "percent-encoding", + "portable-atomic", "rand 0.9.4", "thiserror", ] @@ -2158,7 +2124,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", + "redox_syscall", "smallvec", "windows-link", ] @@ -2294,17 +2260,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pkcs1" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" -dependencies = [ - "der", - "pkcs8", - "spki", -] - [[package]] name = "pkcs8" version = "0.10.2" @@ -2321,12 +2276,6 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - [[package]] name = "portable-atomic" version = "1.13.1" @@ -2441,6 +2390,15 @@ dependencies = [ "syn", ] +[[package]] +name = "prost-types" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +dependencies = [ + "prost", +] + [[package]] name = "quinn" version = "0.11.9" @@ -2631,15 +2589,6 @@ dependencies = [ "bitflags", ] -[[package]] -name = "redox_syscall" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" -dependencies = [ - "bitflags", -] - [[package]] name = "regex" version = "1.12.3" @@ -2675,46 +2624,6 @@ version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" -[[package]] -name = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-native-certs", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "reqwest" version = "0.13.3" @@ -2723,7 +2632,9 @@ checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" dependencies = [ "base64", "bytes", + "futures-channel", "futures-core", + "futures-util", "http", "http-body", "http-body-util", @@ -2773,26 +2684,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rsa" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" -dependencies = [ - "const-oid 0.9.6", - "digest 0.10.7", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8", - "rand_core 0.6.4", - "signature", - "spki", - "subtle", - "zeroize", -] - [[package]] name = "rstest" version = "0.26.1" @@ -3023,9 +2914,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -3081,13 +2972,13 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -3243,9 +3134,9 @@ dependencies = [ [[package]] name = "sqlx" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" dependencies = [ "sqlx-core", "sqlx-macros", @@ -3256,12 +3147,13 @@ dependencies = [ [[package]] name = "sqlx-core" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" dependencies = [ "base64", "bytes", + "cfg-if", "chrono", "crc", "crossbeam-queue", @@ -3271,12 +3163,11 @@ dependencies = [ "futures-intrusive", "futures-io", "futures-util", - "hashbrown 0.15.5", + "hashbrown 0.16.1", "hashlink", "indexmap", "log", "memchr", - "once_cell", "percent-encoding", "serde", "serde_json", @@ -3292,9 +3183,9 @@ dependencies = [ [[package]] name = "sqlx-macros" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" dependencies = [ "proc-macro2", "quote", @@ -3305,15 +3196,15 @@ dependencies = [ [[package]] name = "sqlx-macros-core" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" dependencies = [ + "cfg-if", "dotenvy", "either", "heck", "hex", - "once_cell", "proc-macro2", "quote", "serde", @@ -3324,59 +3215,44 @@ dependencies = [ "sqlx-postgres", "sqlx-sqlite", "syn", + "thiserror", "tokio", "url", ] [[package]] name = "sqlx-mysql" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" dependencies = [ - "atoi", - "base64", "bitflags", "byteorder", "bytes", "chrono", "crc", - "digest 0.10.7", + "digest 0.11.3", "dotenvy", "either", - "futures-channel", "futures-core", - "futures-io", "futures-util", "generic-array", - "hex", - "hkdf", - "hmac", - "itoa", "log", - "md-5 0.10.6", - "memchr", - "once_cell", "percent-encoding", - "rand 0.8.6", - "rsa", "serde", "sha1", - "sha2 0.10.9", - "smallvec", + "sha2 0.11.0", "sqlx-core", - "stringprep", "thiserror", "tracing", "uuid", - "whoami", ] [[package]] name = "sqlx-postgres" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", "base64", @@ -3392,16 +3268,14 @@ dependencies = [ "hex", "hkdf", "hmac", - "home", "itoa", "log", - "md-5 0.10.6", + "md-5", "memchr", - "once_cell", - "rand 0.8.6", + "rand 0.10.1", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", "smallvec", "sqlx-core", "stringprep", @@ -3413,13 +3287,14 @@ dependencies = [ [[package]] name = "sqlx-sqlite" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" dependencies = [ "atoi", "chrono", "flume", + "form_urlencoded", "futures-channel", "futures-core", "futures-executor", @@ -3429,7 +3304,6 @@ dependencies = [ "log", "percent-encoding", "serde", - "serde_urlencoded", "sqlx-core", "thiserror", "tracing", @@ -3794,6 +3668,17 @@ dependencies = [ "tonic", ] +[[package]] +name = "tonic-types" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab1b02061f83d519bba3caa167f88f261ef05720ab8ebc954ade70de3348e8" +dependencies = [ + "prost", + "prost-types", + "tonic", +] + [[package]] name = "tower" version = "0.5.3" @@ -3889,9 +3774,9 @@ dependencies = [ [[package]] name = "tracing-opentelemetry" -version = "0.32.1" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac28f2d093c6c477eaa76b23525478f38de514fa9aeb1285738d4b97a9552fc" +checksum = "adbc64cba7137545b8044cb1fe9814f7aacf3c6b5f9b45be8bb5db538befdb26" dependencies = [ "js-sys", "opentelemetry", @@ -4117,12 +4002,6 @@ dependencies = [ "wit-bindgen 0.51.0", ] -[[package]] -name = "wasite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" - [[package]] name = "wasm-bindgen" version = "0.2.121" @@ -4261,13 +4140,9 @@ dependencies = [ [[package]] name = "whoami" -version = "1.6.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" -dependencies = [ - "libredox", - "wasite", -] +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" [[package]] name = "widestring" @@ -4354,15 +4229,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -4390,21 +4256,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -4438,12 +4289,6 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -4456,12 +4301,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -4474,12 +4313,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -4504,12 +4337,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -4522,12 +4349,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -4540,12 +4361,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -4558,12 +4373,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index 6a2aeeea..a715953f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,16 +38,16 @@ anyhow = "1" clap = { version = "4.5.23", features = ["derive", "env"] } serde_yaml = "0.9" regex = "1" -sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "chrono", "json", "uuid"] } +sqlx = { version = "0.9", features = ["runtime-tokio", "postgres", "chrono", "json", "uuid"] } tera = "1" hickory-resolver = { version = "0.26", default-features = false, features = ["tokio", "system-config"] } # OpenTelemetry -opentelemetry = "0.31" -opentelemetry_sdk = { version = "0.31", features = ["trace"] } -opentelemetry-otlp = { version = "0.31", features = ["grpc-tonic", "http-proto", "reqwest-rustls", "trace"] } -tracing-opentelemetry = "0.32" -opentelemetry-semantic-conventions = "0.31" +opentelemetry = "0.32" +opentelemetry_sdk = { version = "0.32", features = ["trace"] } +opentelemetry-otlp = { version = "0.32", features = ["grpc-tonic", "http-proto", "reqwest-rustls", "trace"] } +tracing-opentelemetry = "0.33" +opentelemetry-semantic-conventions = "0.32" # Fast deploy profile: optimized for compile speed, acceptable runtime perf. # Use `task ec2:deploy BUILD_PROFILE=release` for production-grade optimization. diff --git a/ansible/requirements.yml b/ansible/requirements.yml index 925ac61a..496a355b 100644 --- a/ansible/requirements.yml +++ b/ansible/requirements.yml @@ -9,9 +9,9 @@ collections: - name: community.docker version: 5.2.0 - name: ansible.posix - version: 2.1.0 + version: 2.2.0 - name: community.general - version: 12.6.1 + version: 13.0.0 - name: grafana.grafana version: 6.1.0 - name: https://github.com/CowDogMoo/ansible-collection-workstation.git diff --git a/ares-cli/src/history/cost.rs b/ares-cli/src/history/cost.rs index fcc4b7d1..511cbe2c 100644 --- a/ares-cli/src/history/cost.rs +++ b/ares-cli/src/history/cost.rs @@ -1,5 +1,6 @@ use anyhow::Result; use chrono::Utc; +use sqlx::AssertSqlSafe; use super::connect_postgres; use super::types::CostRow; @@ -36,7 +37,7 @@ pub(crate) async fn history_cost( bind_idx += 1; query.push_str(&format!(" ORDER BY started_at DESC LIMIT ${bind_idx}")); - let mut q = sqlx::query_as::<_, CostRow>(&query); + let mut q = sqlx::query_as::<_, CostRow>(AssertSqlSafe(query)); if let Some(ref d) = domain { q = q.bind(format!("%{d}%")); diff --git a/ares-cli/src/history/list.rs b/ares-cli/src/history/list.rs index 8ee154bd..b6c21e9e 100644 --- a/ares-cli/src/history/list.rs +++ b/ares-cli/src/history/list.rs @@ -1,5 +1,6 @@ use anyhow::Result; use chrono::Utc; +use sqlx::AssertSqlSafe; use super::connect_postgres; use super::types::OperationRow; @@ -48,7 +49,7 @@ pub(crate) async fn history_list( bind_idx += 1; query.push_str(&format!(" ORDER BY started_at DESC LIMIT ${bind_idx}")); - let mut q = sqlx::query_as::<_, OperationRow>(&query); + let mut q = sqlx::query_as::<_, OperationRow>(AssertSqlSafe(query)); if let Some(ref d) = domain { q = q.bind(format!("%{d}%")); diff --git a/ares-cli/src/history/search.rs b/ares-cli/src/history/search.rs index 449c639e..ed7352bb 100644 --- a/ares-cli/src/history/search.rs +++ b/ares-cli/src/history/search.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use sqlx::AssertSqlSafe; use super::connect_postgres; use super::types::{CredentialSearchRow, HashSearchRow}; @@ -40,7 +41,7 @@ pub(crate) async fn history_search_creds( bind_idx += 1; query.push_str(&format!(" ORDER BY c.created_at DESC LIMIT ${bind_idx}")); - let mut q = sqlx::query_as::<_, CredentialSearchRow>(&query); + let mut q = sqlx::query_as::<_, CredentialSearchRow>(AssertSqlSafe(query)); if let Some(ref d) = domain { q = q.bind(d); @@ -139,7 +140,7 @@ pub(crate) async fn history_search_hashes( bind_idx += 1; query.push_str(&format!(" ORDER BY h.created_at DESC LIMIT ${bind_idx}")); - let mut q = sqlx::query_as::<_, HashSearchRow>(&query); + let mut q = sqlx::query_as::<_, HashSearchRow>(AssertSqlSafe(query)); if let Some(ref d) = domain { q = q.bind(d); diff --git a/ares-cli/src/main.rs b/ares-cli/src/main.rs index 76a5f0e4..fbca77d4 100644 --- a/ares-cli/src/main.rs +++ b/ares-cli/src/main.rs @@ -58,10 +58,15 @@ async fn main() { // ── Initialize telemetry before using tracing macros ── // Skip for orchestrator/worker subcommands — they init their own telemetry - // with the correct service name. + // with the correct service name. The subcommand can appear anywhere in argv + // because clap allows global flags (e.g. `--redis-url `) to precede + // it, so we scan rather than checking `args().nth(1)`. If we mis-detect, the + // telemetry init in ares-core is idempotent (`try_init`-based) and the + // redundant call returns a no-op guard, but mis-detection still bakes the + // wrong service name into spans for the entire process lifetime. let is_service_subcommand = std::env::args() - .nth(1) - .is_some_and(|a| a == "orchestrator" || a == "worker"); + .skip(1) + .any(|a| a == "orchestrator" || a == "worker"); let _telemetry = if !is_service_subcommand { Some(ares_core::telemetry::init_telemetry( ares_core::telemetry::TelemetryConfig::new("ares-cli") diff --git a/ares-cli/src/orchestrator/mod.rs b/ares-cli/src/orchestrator/mod.rs index 0d4b1c0d..337152e4 100644 --- a/ares-cli/src/orchestrator/mod.rs +++ b/ares-cli/src/orchestrator/mod.rs @@ -425,6 +425,21 @@ async fn run_inner() -> Result<()> { let (provider, model_name) = ares_llm::create_provider(&model_spec).context("Failed to create LLM provider")?; + // Fail fast on org/auth misconfigurations before queueing any tasks. A + // typical pitfall: `gpt-5.2` defaults are org-allowlisted at OpenAI, so + // submitting a multi-host op against a non-allowlisted key would silently + // burn through dispatch → LLM → 403 on every single task. A single + // pre-flight call surfaces the error once, with a hint pointing at + // `OPENAI_ORG_ID` / `ARES_LLM_MODEL`. + if let Err(e) = preflight_llm_provider(provider.as_ref(), &model_name).await { + error!( + model = %model_name, + "LLM preflight failed: {e:#} — aborting startup. Set ARES_LLM_MODEL to a widely-available model (e.g. openai/gpt-4o-mini) or ensure the org tied to the API key has access to this model." + ); + return Err(e.context(format!("LLM preflight failed for model '{model_name}'"))); + } + info!(model = %model_name, "LLM preflight ok"); + // Credential auth throttle — prevents AD account lockout by rate-limiting // auth-bearing tool calls per credential. Max 3 attempts per 30s window. // AD lockout: 3 bad attempts / 30 min. With multiple concurrent agents, @@ -891,6 +906,40 @@ async fn run_inner() -> Result<()> { Ok(()) } +/// Issue a minimal LLM chat request to verify the API key + model + org +/// permissions are good before queueing any tasks. We send a 1-token "ping" +/// so the call is cheap; the response content is discarded. A non-retryable +/// error (auth, org-restricted model, bad model name) aborts startup; a +/// retryable error (network, 5xx, rate limit) is treated as a transient +/// upstream blip and only warns. +async fn preflight_llm_provider( + provider: &dyn ares_llm::LlmProvider, + model_name: &str, +) -> Result<()> { + use ares_llm::{ChatMessage, LlmError, LlmRequest, Role}; + + // If the operator explicitly opts out (air-gapped tests, recorded + // fixtures), skip the network call. + if std::env::var("ARES_LLM_PREFLIGHT_SKIP").as_deref() == Ok("1") { + info!("ARES_LLM_PREFLIGHT_SKIP=1; skipping LLM preflight ping"); + return Ok(()); + } + + let mut req = LlmRequest::new(model_name); + req.max_tokens = 1; + req.messages.push(ChatMessage::text(Role::User, "ping")); + + match provider.chat(&req).await { + Ok(_) => Ok(()), + Err(LlmError::AuthError(msg)) => Err(anyhow::anyhow!("authentication failed: {msg}")), + Err(e) if !e.is_retryable() => Err(anyhow::anyhow!("LLM provider rejected preflight: {e}")), + Err(e) => { + warn!(err = %e, "LLM preflight returned a retryable error; continuing startup"); + Ok(()) + } + } +} + /// Run in blue-only mode: just the investigation poller, no red team. /// /// Requires only `ARES_REDIS_URL` and an LLM model. No operation ID needed. diff --git a/ares-cli/src/orchestrator/task_queue.rs b/ares-cli/src/orchestrator/task_queue.rs index 24e7f1ea..c5bc2cee 100644 --- a/ares-cli/src/orchestrator/task_queue.rs +++ b/ares-cli/src/orchestrator/task_queue.rs @@ -113,9 +113,23 @@ struct ResultDemux { } impl ResultDemux { + /// Deterministic durable name for the orchestrator's result-demux pull + /// consumer on `ARES_TASKS`. Using a fixed name (rather than an ephemeral + /// consumer) gives us a handle to delete any leftover instance from a + /// previous orchestrator incarnation before re-creating ours — a fresh + /// orchestrator otherwise hits `JetStream error: filtered consumer not + /// unique on workqueue stream (code 400, error code 10100)` on restart. + const DURABLE_NAME: &'static str = "ares-orch-result-demux"; + /// Create the consumer and spawn the drain loop. Lives for the lifetime /// of the process; the spawned task only exits if the JetStream message /// stream ends (which only happens on shutdown / connection loss). + /// + /// On `ARES_TASKS` (a WorkQueue stream) JetStream enforces that no two + /// consumers share a filter. A prior orchestrator pod that crashed (OOM, + /// SIGKILL, or eviction) leaves its consumer behind, and re-creating ours + /// fails. To stay idempotent on restart we delete any pre-existing + /// consumer with our durable name before creating a fresh one. async fn start(nats: &NatsBroker) -> Result> { use async_nats::jetstream::consumer::pull::Config as PullConfig; use async_nats::jetstream::consumer::{AckPolicy, Consumer}; @@ -126,10 +140,44 @@ impl ResultDemux { .await .with_context(|| format!("get_stream({})", nats::TASKS_STREAM))?; + // Best-effort: delete any leftover consumer from a previous incarnation. + // `delete_consumer` returns `ConsumerError::NotFound` on a clean stream; + // that's the happy path on first boot. + match stream.delete_consumer(Self::DURABLE_NAME).await { + Ok(_) => { + info!( + durable = Self::DURABLE_NAME, + "Deleted stale result-demux consumer from previous orchestrator incarnation" + ); + } + Err(e) => { + // Anything other than "not found" is logged but not fatal — if + // the next create call still trips the uniqueness check we'll + // surface that error to the caller. + let msg = e.to_string().to_lowercase(); + if msg.contains("not found") || msg.contains("consumer not found") { + // Nothing to clean up; normal first-boot path. + } else { + warn!( + durable = Self::DURABLE_NAME, + err = %e, + "Failed to delete prior result-demux consumer (continuing — create_consumer will surface the real error if any)" + ); + } + } + } + let filter = format!("{}.>", nats::TASK_RESULT_SUBJECT_PREFIX); let cfg = PullConfig { + durable_name: Some(Self::DURABLE_NAME.to_string()), + name: Some(Self::DURABLE_NAME.to_string()), filter_subject: filter.clone(), ack_policy: AckPolicy::Explicit, + // Bound how long a stale consumer can linger if we fail to clean + // it up on shutdown (best-effort delete above can race a pod kill). + // After 5 minutes of no pull requests, JetStream evicts it on its + // own and the next orchestrator can take over without manual fix-up. + inactive_threshold: Duration::from_secs(5 * 60), ..Default::default() }; let consumer: Consumer = stream diff --git a/ares-cli/src/orchestrator/tool_dispatcher/mod.rs b/ares-cli/src/orchestrator/tool_dispatcher/mod.rs index 78a43ceb..5b1b460e 100644 --- a/ares-cli/src/orchestrator/tool_dispatcher/mod.rs +++ b/ares-cli/src/orchestrator/tool_dispatcher/mod.rs @@ -60,6 +60,44 @@ pub struct ToolExecResponse { /// behind another hashcat, so 2x runtime + buffer). pub(super) const DEFAULT_TOOL_TIMEOUT_SECS: u64 = 1500; +/// Tools whose worst-case runtime is materially longer than the default +/// allowance and which must not be capped at the dispatcher's generic +/// `DEFAULT_TOOL_TIMEOUT_SECS`. Maps a tool name to its minimum deadline (in +/// seconds) — the effective timeout is `max(DEFAULT_TOOL_TIMEOUT_SECS, value)` +/// so this acts as a floor, not a ceiling. Operators can still override the +/// default via `ARES_TOOL_TIMEOUT_SECS` to lift everything at once. +/// +/// Observed during the 2026-05-26 bring-up: full-port `nmap` service-version +/// scans against a Windows DC routinely take 60-180s, and `smb_sweep` / +/// `smb_signing_check` against a /24 can queue behind serialized smbclient +/// invocations. The original 10s NATS client `request_timeout` defeated even +/// the dispatcher's generous outer `tokio::time::timeout`; with the broker +/// timeout raised in `ares-core`, this table gives the dispatcher a way to +/// bump individual slow tools without touching every other code path. +pub(super) fn per_tool_timeout_floor_secs(tool_name: &str) -> Option { + match tool_name { + // nmap full-port + service version against Windows DC: ~60-180s + // observed; allow 10x headroom for slow / heavily filtered hosts. + "nmap_scan" => Some(30 * 60), + // smbclient enumeration against a /24 can serialize for minutes. + "smb_sweep" | "smb_signing_check" | "enumerate_shares" => Some(20 * 60), + // netexec-driven AD checks; chained logon attempts add up. + "domain_admin_checker" | "password_spray" | "username_as_password" => Some(20 * 60), + _ => None, + } +} + +/// Compute the dispatch deadline for a given tool. +pub(super) fn tool_timeout_for( + tool_name: &str, + default: std::time::Duration, +) -> std::time::Duration { + match per_tool_timeout_floor_secs(tool_name) { + Some(floor) if floor > default.as_secs() => std::time::Duration::from_secs(floor), + _ => default, + } +} + /// Tools that require netexec/ldapsearch and must be routed to the recon /// worker queue regardless of the calling agent's role. const RECON_ROUTED_TOOLS: &[&str] = &[ diff --git a/ares-cli/src/orchestrator/tool_dispatcher/redis_dispatcher.rs b/ares-cli/src/orchestrator/tool_dispatcher/redis_dispatcher.rs index 1c122c94..1252ba76 100644 --- a/ares-cli/src/orchestrator/tool_dispatcher/redis_dispatcher.rs +++ b/ares-cli/src/orchestrator/tool_dispatcher/redis_dispatcher.rs @@ -189,7 +189,9 @@ impl ares_llm::ToolDispatcher for RedisToolDispatcher { .context("ToolDispatcher requires NATS broker")?; let client = nats.client().clone(); - let timeout = self.tool_timeout; + // Promote slow tools (nmap, smb_*, password_spray, etc.) above the + // shared default; everything else uses the configured tool_timeout. + let timeout = super::tool_timeout_for(&call.name, self.tool_timeout); let response_msg = match tokio::time::timeout( timeout, client.request(subject.clone(), Bytes::from(payload)), diff --git a/ares-cli/src/orchestrator/tool_dispatcher/tests.rs b/ares-cli/src/orchestrator/tool_dispatcher/tests.rs index 6d6a713a..deb7fc2b 100644 --- a/ares-cli/src/orchestrator/tool_dispatcher/tests.rs +++ b/ares-cli/src/orchestrator/tool_dispatcher/tests.rs @@ -702,3 +702,44 @@ fn tool_exec_result_from_response_preserves_error_string() { assert_eq!(r.error.as_deref(), Some("connection refused")); assert!(r.discoveries.is_none()); } + +#[test] +fn tool_timeout_for_slow_recon_tools_lifts_above_small_default() { + use std::time::Duration; + // Regression for the 2026-05-26 timeout: an operator who overrode the + // dispatcher default down (or any future code path that supplies a small + // value) must still get a generous per-tool floor for nmap / smb_*. + let tiny = Duration::from_secs(60); + assert_eq!( + tool_timeout_for("nmap_scan", tiny), + Duration::from_secs(30 * 60) + ); + assert_eq!( + tool_timeout_for("smb_sweep", tiny), + Duration::from_secs(20 * 60) + ); + assert_eq!( + tool_timeout_for("password_spray", tiny), + Duration::from_secs(20 * 60) + ); +} + +#[test] +fn tool_timeout_for_unlisted_tool_uses_default() { + use std::time::Duration; + let default = Duration::from_secs(DEFAULT_TOOL_TIMEOUT_SECS); + assert_eq!(tool_timeout_for("whoami", default), default); + assert_eq!(tool_timeout_for("nslookup", default), default); +} + +#[test] +fn tool_timeout_floor_never_lowers_a_higher_caller_default() { + use std::time::Duration; + // If the dispatcher default is already above the per-tool floor (which is + // the case for `smb_sweep` and the in-tree `DEFAULT_TOOL_TIMEOUT_SECS`), + // we must not silently lower it. The floor is a minimum, not a cap. + let default = Duration::from_secs(DEFAULT_TOOL_TIMEOUT_SECS); + assert_eq!(tool_timeout_for("smb_sweep", default), default); + let huge = Duration::from_secs(60 * 60); + assert_eq!(tool_timeout_for("nmap_scan", huge), huge); +} diff --git a/ares-core/src/nats.rs b/ares-core/src/nats.rs index 2e6949c9..ac0a1fe1 100644 --- a/ares-core/src/nats.rs +++ b/ares-core/src/nats.rs @@ -149,14 +149,32 @@ pub struct NatsBroker { jetstream: JetStreamContext, } +/// Default `request_timeout` applied to the underlying `async-nats` client. +/// +/// `async-nats` defaults this to 10s, which is far too short for our tool +/// dispatch path: an `nmap` full-port scan against a Windows DC routinely +/// takes 60-180s, and `password_spray` can queue behind an auth throttle. +/// Per-call timeouts are still enforced by the dispatcher +/// (`tokio::time::timeout` around `client.request`), so the only thing this +/// value controls is the *upper bound* the NATS client will wait before +/// surfacing `request timed out: deadline has elapsed`. Set it well above +/// the longest individual tool timeout the dispatcher will impose. +const CLIENT_REQUEST_TIMEOUT_SECS: u64 = 30 * 60; + impl NatsBroker { /// Connect to NATS at the given URL (e.g. `nats://nats.attack-simulation.svc:4222`). pub async fn connect(url: &str) -> Result { - let client = async_nats::connect(url) + let client = async_nats::ConnectOptions::new() + .request_timeout(Some(Duration::from_secs(CLIENT_REQUEST_TIMEOUT_SECS))) + .connect(url) .await .with_context(|| format!("Failed to connect to NATS at {url}"))?; let jetstream = jetstream::new(client.clone()); - info!(url, "Connected to NATS"); + info!( + url, + request_timeout_secs = CLIENT_REQUEST_TIMEOUT_SECS, + "Connected to NATS" + ); Ok(Self { client, jetstream }) } diff --git a/ares-core/src/persistent_store/queries/credentials.rs b/ares-core/src/persistent_store/queries/credentials.rs index 88356c7e..2a27b371 100644 --- a/ares-core/src/persistent_store/queries/credentials.rs +++ b/ares-core/src/persistent_store/queries/credentials.rs @@ -1,6 +1,7 @@ //! Credential and hash search queries across all operations. use anyhow::Result; +use sqlx::AssertSqlSafe; use super::rows::{CredentialRow, HashRow}; use super::HistoricalQueryService; @@ -198,17 +199,19 @@ impl HistoricalQueryService { ); // Bind dynamically — sqlx doesn't support dynamic binds easily, - // so we use query_scalar pattern with explicit bind count + // so we use query_scalar pattern with explicit bind count. + // SQL is built from static fragments plus $N placeholder indices only; + // user-controlled values are passed via .bind() — safe to assert. match bind_values.len() { 1 => { - sqlx::query_as::<_, HashRow>(&sql) + sqlx::query_as::<_, HashRow>(AssertSqlSafe(sql)) .bind(&bind_values[0]) .bind(limit) .fetch_all(&self.pool) .await? } 2 => { - sqlx::query_as::<_, HashRow>(&sql) + sqlx::query_as::<_, HashRow>(AssertSqlSafe(sql)) .bind(&bind_values[0]) .bind(&bind_values[1]) .bind(limit) @@ -216,7 +219,7 @@ impl HistoricalQueryService { .await? } 3 => { - sqlx::query_as::<_, HashRow>(&sql) + sqlx::query_as::<_, HashRow>(AssertSqlSafe(sql)) .bind(&bind_values[0]) .bind(&bind_values[1]) .bind(&bind_values[2]) diff --git a/ares-core/src/telemetry/init.rs b/ares-core/src/telemetry/init.rs index bbfeaec2..4c674094 100644 --- a/ares-core/src/telemetry/init.rs +++ b/ares-core/src/telemetry/init.rs @@ -49,17 +49,32 @@ impl TelemetryConfig { /// graceful exit to flush pending spans. pub struct TelemetryGuard { provider: Option, + /// `true` when this guard is the no-op shim returned after a redundant + /// [`init_telemetry`] call. Such guards do not own a provider and must + /// not run shutdown. + already_initialized: bool, } impl TelemetryGuard { /// Flush and shut down the tracer provider. Safe to call multiple times. pub fn shutdown(&mut self) { + if self.already_initialized { + return; + } if let Some(provider) = self.provider.take() { if let Err(e) = provider.shutdown() { eprintln!("telemetry shutdown error: {e}"); } } } + + /// Returns true if this guard is a no-op shim because the tracing + /// subscriber had already been installed by a previous call. Exposed for + /// the regression test. + #[cfg(test)] + pub fn is_noop(&self) -> bool { + self.already_initialized + } } impl Drop for TelemetryGuard { @@ -96,28 +111,63 @@ pub fn init_telemetry(config: TelemetryConfig) -> TelemetryGuard { let tracer = provider.tracer(config.service_name.clone()); let otel_layer = OpenTelemetryLayer::new(tracer); - tracing_subscriber::registry() + // `try_init` returns Err if a global subscriber is already set + // (e.g. the CLI initialized one before dispatching to a long-running + // subcommand that wants its own service name). Treat that as a + // soft success: log a notice and return a no-op guard, instead of + // panicking the process at startup. + let init_result = tracing_subscriber::registry() .with(env_filter) .with(fmt_layer) .with(otel_layer) - .init(); - - tracing::info!( - service = %config.service_name, - "telemetry initialized with OTLP exporter" - ); + .try_init(); - TelemetryGuard { - provider: Some(provider), + match init_result { + Ok(()) => { + tracing::info!( + service = %config.service_name, + "telemetry initialized with OTLP exporter" + ); + TelemetryGuard { + provider: Some(provider), + already_initialized: false, + } + } + Err(_) => { + // Subscriber already installed — discard the freshly built + // OTel provider so we don't leak a BatchSpanProcessor that + // nothing is wired into. The pre-existing subscriber stays + // authoritative for this process. + if let Err(e) = provider.shutdown() { + eprintln!("telemetry: dropped redundant provider shutdown error: {e}"); + } + tracing::debug!( + service = %config.service_name, + "telemetry already initialized by earlier call; using existing subscriber" + ); + TelemetryGuard { + provider: None, + already_initialized: true, + } + } } } None => { - tracing_subscriber::registry() + let init_result = tracing_subscriber::registry() .with(env_filter) .with(fmt_layer) - .init(); + .try_init(); - TelemetryGuard { provider: None } + match init_result { + Ok(()) => TelemetryGuard { + provider: None, + already_initialized: false, + }, + Err(_) => TelemetryGuard { + provider: None, + already_initialized: true, + }, + } } } } @@ -204,3 +254,37 @@ fn try_init_otel_provider(service_name: &str) -> Option { Some(provider) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Regression for the orchestrator double-init crash. + /// + /// Originally `init_telemetry` called `.init()` (which panics if a global + /// dispatcher is already set). Running `ares --redis-url orchestrator` + /// would init once in `main` and again in `orchestrator::run`, panicking + /// with `SetGlobalDefaultError`. After the fix, the second call must + /// return a no-op `TelemetryGuard` instead of crashing the process. + #[test] + fn double_init_returns_noop_guard_instead_of_panicking() { + // First call wins and installs the subscriber. + let first = init_telemetry(TelemetryConfig::new("ares-test-first")); + // Second call must not panic; it returns a guard flagged as noop. + let second = init_telemetry(TelemetryConfig::new("ares-test-second")); + + assert!( + !first.is_noop(), + "first init_telemetry call should own the subscriber" + ); + assert!( + second.is_noop(), + "second init_telemetry call must return a no-op guard, not panic" + ); + + // Dropping the noop guard must not panic / shutdown anything; dropping + // the real guard runs the normal shutdown path. + drop(second); + drop(first); + } +} diff --git a/ares-llm/src/provider/openai.rs b/ares-llm/src/provider/openai.rs index 012d3a12..c36a27cf 100644 --- a/ares-llm/src/provider/openai.rs +++ b/ares-llm/src/provider/openai.rs @@ -248,6 +248,33 @@ fn uses_max_completion_tokens(model: &str) -> bool { model.starts_with("gpt-5") } +/// Heuristically detect OpenAI 403 messages that are caused by the API key's +/// organization not being allowlisted for the requested model. Restricted +/// models like `gpt-5.2` raise this on the *first* call, so catching it +/// cheaply lets the orchestrator fail fast with a useful hint instead of +/// letting every queued task tip over with the same opaque error. +pub(crate) fn is_org_restricted_message(msg: &str) -> bool { + let lower = msg.to_lowercase(); + lower.contains("do not have access to the organization") + || lower.contains("must be verified to use the model") + || lower.contains("not have access to model") + || lower.contains("project does not have access") +} + +/// Append a one-line operator hint to org-restricted / auth errors so the +/// failure log immediately points at the likely cause (wrong model default or +/// missing `OPENAI_ORG_ID`). Kept best-effort: if the upstream message +/// already contains a usable pointer, we don't duplicate it. +pub(crate) fn augment_org_hint(message: &str, model: &str) -> String { + let already_hinted = message.contains("OPENAI_ORG_ID") || message.contains("ARES_LLM_MODEL"); + if already_hinted { + return message.to_string(); + } + format!( + "{message} [model={model} — check OPENAI_ORG_ID and that your org is allowlisted for this model, or set ARES_LLM_MODEL to a widely-available alternative such as openai/gpt-4o-mini]" + ) +} + #[async_trait::async_trait] impl LlmProvider for OpenAiProvider { async fn chat(&self, request: &LlmRequest) -> Result { @@ -327,7 +354,15 @@ impl LlmProvider for OpenAiProvider { return Err(match status.as_u16() { 429 => LlmError::RateLimited { retry_after_ms }, - 401 => LlmError::AuthError(message), + // 401 = bad/missing API key. 403 with org-restriction phrasing + // means the key is valid but the org isn't allowlisted for the + // requested model (typical for `gpt-5.2` and other restricted + // models). Surface both as AuthError so callers fail fast with + // a clearer message instead of treating it as a generic 4xx. + 401 => LlmError::AuthError(augment_org_hint(&message, &request.model)), + 403 if is_org_restricted_message(&message) => { + LlmError::AuthError(augment_org_hint(&message, &request.model)) + } _ => LlmError::ApiError { status: status.as_u16(), message, @@ -469,4 +504,45 @@ mod tests { assert!(uses_max_completion_tokens("openai/gpt-5.2")); assert!(!uses_max_completion_tokens("gpt-4o-mini")); } + + #[test] + fn detects_org_restricted_messages() { + // Real 403 string observed when running against a non-allowlisted org. + assert!(is_org_restricted_message( + "You do not have access to the organization tied to the API key." + )); + // Verified-org wording for gated models (currently surfaces on gpt-5.2). + assert!(is_org_restricted_message( + "Your organization must be verified to use the model `gpt-5.2`." + )); + // Project-level access denial (project-scoped API keys). + assert!(is_org_restricted_message( + "This project does not have access to model `gpt-5.2`." + )); + // Unrelated 4xx must not be classified as org-restricted. + assert!(!is_org_restricted_message( + "Invalid request: temperature out of range" + )); + assert!(!is_org_restricted_message("Rate limit exceeded")); + } + + #[test] + fn augment_org_hint_adds_actionable_pointers() { + let augmented = augment_org_hint( + "You do not have access to the organization tied to the API key.", + "gpt-5.2", + ); + assert!(augmented.contains("OPENAI_ORG_ID")); + assert!(augmented.contains("ARES_LLM_MODEL")); + assert!(augmented.contains("gpt-5.2")); + } + + #[test] + fn augment_org_hint_is_idempotent() { + // If the upstream message already mentions one of our pointers (e.g. + // operator already saw the augmented message once and re-raised it), + // we don't double up. + let pre_augmented = "Some upstream wrapper said: set OPENAI_ORG_ID"; + assert_eq!(augment_org_hint(pre_augmented, "gpt-5.2"), pre_augmented,); + } } diff --git a/ares-tools/src/cracker.rs b/ares-tools/src/cracker.rs index 2f9c4d3e..844441c8 100644 --- a/ares-tools/src/cracker.rs +++ b/ares-tools/src/cracker.rs @@ -7,6 +7,8 @@ use crate::args::{optional_bool, optional_i64, optional_str, required_str}; use crate::executor::CommandBuilder; use crate::ToolOutput; +mod remote; + /// Default wordlists tried in order. const DEFAULT_WORDLISTS: &[&str] = &[ "/usr/share/wordlists/rockyou.txt", @@ -88,6 +90,10 @@ fn capitalize(s: &str) -> String { /// Tries multiple wordlists in order (rockyou, seclists). When `use_dynamic_wordlist` /// is true (default), also prepends a username-derived candidate list. pub async fn crack_with_hashcat(args: &Value) -> Result { + if let Some(url) = remote::service_url() { + return remote::crack(args, &url).await; + } + let hash_value = required_str(args, "hash_value")?; let explicit_wordlist = optional_str(args, "wordlist_path"); let explicit_rules = optional_str(args, "rules_file"); diff --git a/ares-tools/src/cracker/remote.rs b/ares-tools/src/cracker/remote.rs new file mode 100644 index 00000000..0415dfdd --- /dev/null +++ b/ares-tools/src/cracker/remote.rs @@ -0,0 +1,190 @@ +//! Remote hashcat backend. +//! +//! When `HASHCAT_SERVICE_URL` (and `HASHCAT_TOKEN`) are set in the cracker +//! agent's env, [`crack_with_hashcat`](super::crack_with_hashcat) delegates to +//! an HTTP service instead of spawning hashcat locally. The remote service +//! owns the GPU and the wordlist directory; the agent becomes a thin client. +//! +//! Expected service contract: +//! - `POST /jobs` with `{hash_mode, attack_mode, hashes[], wordlist?, mask?}` +//! and `Authorization: Bearer ` → `{job_id, status}`. +//! - `GET /jobs/{id}` → `{status, log_tail?, error?}` where status is one of +//! `starting | running | done | error`. +//! - `GET /jobs/{id}/potfile` → `{cracked: [":", ...]}`. +//! +//! Scope of remote mode: wordlist attack (`-a 0`) with a single wordlist by +//! basename. Rules-based and dynamic username wordlists stay local-only — +//! the service's wordlist directory is its own concern. + +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::args::{optional_i64, optional_str, required_str}; +use crate::ToolOutput; + +use super::{detect_hashcat_mode, DEFAULT_MAX_TIME_MINUTES}; + +const DEFAULT_REMOTE_WORDLIST: &str = "rockyou.txt"; +const POLL_INTERVAL_SECS: u64 = 5; + +/// Returns the configured remote service URL, or `None` if remote mode is off. +pub(super) fn service_url() -> Option<String> { + std::env::var("HASHCAT_SERVICE_URL") + .ok() + .filter(|s| !s.is_empty()) +} + +fn service_token() -> Result<String> { + std::env::var("HASHCAT_TOKEN") + .context("HASHCAT_SERVICE_URL is set but HASHCAT_TOKEN is missing") +} + +fn http_client() -> reqwest::Client { + reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .unwrap_or_default() +} + +#[derive(Serialize)] +struct JobSubmission<'a> { + hash_mode: i64, + attack_mode: i64, + hashes: Vec<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + wordlist: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + mask: Option<&'a str>, +} + +#[derive(Deserialize)] +struct JobIdResponse { + job_id: String, +} + +#[derive(Deserialize)] +struct JobStateResponse { + status: String, + #[serde(default)] + log_tail: String, + #[serde(default)] + error: Option<String>, +} + +#[derive(Deserialize, Default)] +struct PotfileResponse { + #[serde(default)] + cracked: Vec<String>, +} + +/// Take the basename of a path. Remote services typically refuse absolute +/// paths and only accept filenames within their own wordlist directory. +fn basename(path: &str) -> String { + std::path::Path::new(path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(path) + .to_string() +} + +pub(super) async fn crack(args: &Value, base_url: &str) -> Result<ToolOutput> { + let hash_value = required_str(args, "hash_value")?; + let token = service_token()?; + let mode = + optional_i64(args, "hashcat_mode").unwrap_or_else(|| detect_hashcat_mode(hash_value)); + let max_time_minutes = optional_i64(args, "max_time_minutes") + .unwrap_or(DEFAULT_MAX_TIME_MINUTES) + .max(DEFAULT_MAX_TIME_MINUTES); + let max_time_secs = (max_time_minutes * 60) as u64; + let wordlist = optional_str(args, "wordlist_path") + .map(basename) + .unwrap_or_else(|| DEFAULT_REMOTE_WORDLIST.to_string()); + + let client = http_client(); + let url = base_url.trim_end_matches('/'); + + let submission = JobSubmission { + hash_mode: mode, + attack_mode: 0, + hashes: vec![hash_value], + wordlist: Some(wordlist), + mask: None, + }; + + // Submit. + let job_id = { + let resp = client + .post(format!("{url}/jobs")) + .bearer_auth(&token) + .json(&submission) + .send() + .await + .context("crackd: failed to POST /jobs")?; + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + if !status.is_success() { + return Ok(ToolOutput { + stdout: String::new(), + stderr: format!("crackd submission failed ({status}): {body}"), + exit_code: Some(1), + success: false, + }); + } + serde_json::from_str::<JobIdResponse>(&body) + .context("crackd: unexpected /jobs response shape")? + .job_id + }; + + // Poll. + let started = Instant::now(); + let (terminal_status, last_log, last_error) = loop { + let resp = client + .get(format!("{url}/jobs/{job_id}")) + .bearer_auth(&token) + .send() + .await + .context("crackd: failed to GET /jobs/{id}")?; + let body = resp.text().await.unwrap_or_default(); + let state: JobStateResponse = + serde_json::from_str(&body).context("crackd: unexpected /jobs/{id} response shape")?; + if matches!(state.status.as_str(), "done" | "error") { + break (state.status, state.log_tail, state.error); + } + if started.elapsed().as_secs() > max_time_secs { + return Ok(ToolOutput { + stdout: state.log_tail, + stderr: format!("crackd job {job_id} exceeded {max_time_secs}s budget"), + exit_code: Some(124), + success: false, + }); + } + tokio::time::sleep(Duration::from_secs(POLL_INTERVAL_SECS)).await; + }; + + // Pull potfile — partial cracks are useful even on error. + let potfile: PotfileResponse = { + let resp = client + .get(format!("{url}/jobs/{job_id}/potfile")) + .bearer_auth(&token) + .send() + .await + .context("crackd: failed to GET /jobs/{id}/potfile")?; + resp.json().await.unwrap_or_default() + }; + + let stdout = format!( + "{last_log}\n--- crackd potfile ---\n{}", + potfile.cracked.join("\n") + ); + let success = terminal_status == "done"; + + Ok(ToolOutput { + stdout, + stderr: last_error.unwrap_or_default(), + exit_code: Some(if success { 0 } else { 1 }), + success, + }) +}