diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e4613d..cbb9de2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,48 @@ The format is inspired by Keep a Changelog and this project follows Semantic Ver - (none yet) +## 1.0.0 - 2026-04-06 + +### Added + +- Local API server via `wraithrun serve --port 8080` with v1 REST endpoints (#23). + - `GET /api/v1/health` — unauthenticated liveness check returning status, version, uptime. + - `GET /api/v1/ready` — readiness check returning available tool count. + - `POST /api/v1/runs` — start a new investigation run (accepts `task` and optional `max_steps`). + - `GET /api/v1/runs` — list all runs sorted by creation time. + - `GET /api/v1/runs/{id}` — retrieve a single run with full report. + - `POST /api/v1/runs/{id}/cancel` — cancel a queued or running investigation. + - `GET /api/v1/runtime/status` — runtime introspection (mode, tools, concurrency config). +- `wraithrun serve` subcommand alias (equivalent to `--serve`). +- Bearer token authentication on all API endpoints except `/health` (#25). + - Auto-generated UUID token printed at startup; override with `--api-token `. + - Invalid/missing tokens return 401 Unauthorized with audit log warning. +- Request body size limit (1 MiB default) via `tower-http` `RequestBodyLimitLayer` (#25). +- Bind address locked to `127.0.0.1` for local-only access (#25). +- Concurrency limiter: configurable max concurrent runs (default 4) with 429 Too Many Requests response. +- SQLite-backed data store for persistent run and findings storage (#26). + - Schema: `runs`, `findings`, `schema_version` tables with WAL journal mode. + - Versioned migration framework (current schema v1) with idempotent migration. + - `--database ` flag to enable persistent storage; in-memory by default. + - `DataStore` API: `insert_run`, `update_run`, `get_run`, `list_runs`, `backup`, `export_json`. + - Runs auto-persisted on creation and completion when database is configured. +- Embedded web dashboard at `GET /` for browser-based investigation management (#24). + - Run list with real-time polling (5-second refresh). + - Run detail slide panel with findings, severity badges, and final answer. + - Findings explorer with severity filter buttons (Critical/High/Medium/Low/Info). + - Live health panel: server status, version, uptime, tools, mode, concurrent runs. + - New investigation form with Enter-key submission. + - Run cancellation from the dashboard. + - Token-gated access with local storage persistence. + - Dark theme matching WraithRun visual identity. +- New `api_server` workspace crate containing server, routes, data store, and embedded dashboard. +- 19 new tests: 13 API route tests (health, auth reject, auth wrong token, ready, CRUD, cancel, runtime status, concurrency), 6 data store tests (insert/retrieve, update, list ordering, nonexistent, export, migration idempotency). + +### Changed + +- Workspace dependencies updated: added `axum 0.8`, `tower-http 0.6` (cors, trace, limit), `uuid 1.11` (v4, serde), `rusqlite 0.35` (bundled, backup). +- `tokio` workspace feature set expanded with `net` for TCP listener support. + ## 0.13.0 - 2026-04-05 ### Added diff --git a/Cargo.lock b/Cargo.lock index 12f23ec..80a244b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9,7 +9,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", - "getrandom", + "getrandom 0.3.4", "once_cell", "serde", "version_check", @@ -81,6 +81,25 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "api_server" +version = "1.0.0" +dependencies = [ + "anyhow", + "axum", + "core_engine", + "cyber_tools", + "inference_bridge", + "rusqlite", + "serde", + "serde_json", + "tokio", + "tower", + "tower-http", + "tracing", + "uuid", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -92,12 +111,70 @@ dependencies = [ "syn", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.13.1" @@ -231,7 +308,7 @@ dependencies = [ [[package]] name = "core_engine" -version = "0.3.0" +version = "1.0.0" dependencies = [ "anyhow", "async-trait", @@ -295,7 +372,7 @@ dependencies = [ [[package]] name = "cyber_tools" -version = "0.3.0" +version = "1.0.0" dependencies = [ "async-trait", "serde", @@ -428,6 +505,18 @@ dependencies = [ "cc", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "filetime" version = "0.2.27" @@ -451,6 +540,54 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -469,8 +606,21 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", "wasip2", + "wasip3", ] [[package]] @@ -484,18 +634,122 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -509,7 +763,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -527,7 +783,7 @@ dependencies = [ [[package]] name = "inference_bridge" -version = "0.3.0" +version = "1.0.0" dependencies = [ "anyhow", "async-trait", @@ -576,6 +832,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.183" @@ -604,6 +866,17 @@ dependencies = [ "redox_syscall", ] +[[package]] +name = "libsqlite3-sys" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "947e6816f7825b2b45027c2c32e7085da9934defa535de4a6a46b10a4d5257fa" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -641,6 +914,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "matrixmultiply" version = "0.3.10" @@ -657,6 +936,12 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -817,6 +1102,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -859,6 +1150,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -883,6 +1184,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.9.2" @@ -909,7 +1216,7 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "getrandom", + "getrandom 0.3.4", ] [[package]] @@ -987,6 +1294,20 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "rusqlite" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a22715a5d6deef63c637207afbe68d0c72c3f8d0022d7cf9714c442d6157606b" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1012,6 +1333,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -1055,6 +1382,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -1064,6 +1402,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1100,12 +1450,28 @@ dependencies = [ "libc", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "spm_precompiled" version = "0.1.4" @@ -1141,6 +1507,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "tar" version = "0.4.45" @@ -1213,7 +1585,7 @@ dependencies = [ "dary_heap", "derive_builder", "esaxx-rs", - "getrandom", + "getrandom 0.3.4", "indicatif", "itertools", "log", @@ -1246,6 +1618,7 @@ dependencies = [ "mio", "pin-project-lite", "signal-hook-registry", + "socket2", "tokio-macros", "windows-sys", ] @@ -1302,12 +1675,58 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -1396,6 +1815,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "unicode_categories" version = "0.1.1" @@ -1414,12 +1839,30 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -1441,6 +1884,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.115" @@ -1486,6 +1938,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-time" version = "1.1.0" @@ -1525,12 +2011,95 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "wraithrun" -version = "0.3.0" +version = "1.0.0" dependencies = [ "anyhow", + "api_server", "clap", "core_engine", "cyber_tools", diff --git a/Cargo.toml b/Cargo.toml index 7b0d706..b23e8e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,24 +4,29 @@ members = [ "inference_bridge", "cyber_tools", "cli", + "api_server", ] resolver = "2" [workspace.package] edition = "2021" -version = "0.3.0" +version = "1.0.0" license = "MIT" [workspace.dependencies] anyhow = "1.0" async-trait = "0.1" +axum = "0.8" clap = { version = "4.5", features = ["derive"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" toml = "0.8" sha2 = "0.10" thiserror = "1.0" -tokio = { version = "1.44", features = ["macros", "rt-multi-thread", "process", "time"] } +tokio = { version = "1.44", features = ["macros", "rt-multi-thread", "process", "time", "net"] } +tower-http = { version = "0.6", features = ["cors", "trace", "limit"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } -tar = "0.4" \ No newline at end of file +rusqlite = { version = "0.35", features = ["bundled", "backup"] } +tar = "0.4" +uuid = { version = "1.11", features = ["v4", "serde"] } \ No newline at end of file diff --git a/api_server/Cargo.toml b/api_server/Cargo.toml new file mode 100644 index 0000000..3d8d16d --- /dev/null +++ b/api_server/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "api_server" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +axum.workspace = true +rusqlite.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +tower-http.workspace = true +tracing.workspace = true +uuid.workspace = true +core_engine = { path = "../core_engine" } +cyber_tools = { path = "../cyber_tools" } +inference_bridge = { path = "../inference_bridge" } + +[dev-dependencies] +tower = "0.5" diff --git a/api_server/src/dashboard.html b/api_server/src/dashboard.html new file mode 100644 index 0000000..95eb7b9 --- /dev/null +++ b/api_server/src/dashboard.html @@ -0,0 +1,367 @@ + + + + + +WraithRun Dashboard + + + + +
+
+

WraithRun API Token

+

Enter the API token shown when the server started.

+ + +
+
+ +
+

WraithRun

+
+ + connecting… + +
+
+ +
+
+
Runs
+
Findings
+
Health
+
+ + +
+
+ + +
+
+
+ + + + + + +
+ + +
+ × +
+
+ + + + diff --git a/api_server/src/data_store.rs b/api_server/src/data_store.rs new file mode 100644 index 0000000..5f175da --- /dev/null +++ b/api_server/src/data_store.rs @@ -0,0 +1,348 @@ +use std::path::Path; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use rusqlite::Connection; +use tokio::sync::Mutex; +use uuid::Uuid; + +use crate::state::{RunEntry, RunStatus}; + +/// Current schema version. Increment when adding migrations. +const SCHEMA_VERSION: u32 = 1; + +/// Persistent data store backed by SQLite. +#[derive(Clone)] +pub struct DataStore { + conn: Arc>, +} + +impl DataStore { + /// Open or create a database at the given path. Runs migrations automatically. + pub fn open(path: &Path) -> Result { + let conn = + Connection::open(path).with_context(|| format!("opening database at {}", path.display()))?; + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?; + let store = Self { + conn: Arc::new(Mutex::new(conn)), + }; + store.migrate_sync()?; + Ok(store) + } + + /// Create an in-memory database (for tests). + pub fn open_in_memory() -> Result { + let conn = Connection::open_in_memory()?; + conn.execute_batch("PRAGMA foreign_keys=ON;")?; + let store = Self { + conn: Arc::new(Mutex::new(conn)), + }; + store.migrate_sync()?; + Ok(store) + } + + fn migrate_sync(&self) -> Result<()> { + // We hold the Arc> but since this is called from the + // constructor (before any async context), we use try_lock. + let conn = self.conn.try_lock().expect("migrate called during init"); + migrate(&conn) + } + + pub async fn insert_run(&self, entry: &RunEntry) -> Result<()> { + let conn = self.conn.lock().await; + conn.execute( + "INSERT INTO runs (id, task, status, report_json, error, created_at, completed_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![ + entry.id.to_string(), + entry.task, + status_to_str(&entry.status), + entry.report.as_ref().map(|r| serde_json::to_string(r).unwrap_or_default()), + entry.error, + entry.created_at, + entry.completed_at, + ], + )?; + + // Insert findings from the report if present. + if let Some(report) = &entry.report { + for (i, finding) in report.findings.iter().enumerate() { + conn.execute( + "INSERT INTO findings (id, run_id, seq, title, severity, confidence, recommendation) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![ + Uuid::new_v4().to_string(), + entry.id.to_string(), + i as i64, + finding.title, + format!("{:?}", finding.severity), + finding.confidence, + finding.recommended_action, + ], + )?; + } + } + Ok(()) + } + + pub async fn update_run(&self, entry: &RunEntry) -> Result<()> { + let conn = self.conn.lock().await; + conn.execute( + "UPDATE runs SET status = ?1, report_json = ?2, error = ?3, completed_at = ?4 + WHERE id = ?5", + rusqlite::params![ + status_to_str(&entry.status), + entry.report.as_ref().map(|r| serde_json::to_string(r).unwrap_or_default()), + entry.error, + entry.completed_at, + entry.id.to_string(), + ], + )?; + + // Upsert findings: delete old, insert new. + if let Some(report) = &entry.report { + conn.execute( + "DELETE FROM findings WHERE run_id = ?1", + [entry.id.to_string()], + )?; + for (i, finding) in report.findings.iter().enumerate() { + conn.execute( + "INSERT INTO findings (id, run_id, seq, title, severity, confidence, recommendation) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![ + Uuid::new_v4().to_string(), + entry.id.to_string(), + i as i64, + finding.title, + format!("{:?}", finding.severity), + finding.confidence, + finding.recommended_action, + ], + )?; + } + } + Ok(()) + } + + pub async fn get_run(&self, id: Uuid) -> Result> { + let conn = self.conn.lock().await; + let mut stmt = conn.prepare( + "SELECT id, task, status, report_json, error, created_at, completed_at + FROM runs WHERE id = ?1", + )?; + let mut rows = stmt.query([id.to_string()])?; + match rows.next()? { + Some(row) => Ok(Some(row_to_run_entry(row)?)), + None => Ok(None), + } + } + + pub async fn list_runs(&self) -> Result> { + let conn = self.conn.lock().await; + let mut stmt = conn.prepare( + "SELECT id, task, status, report_json, error, created_at, completed_at + FROM runs ORDER BY created_at DESC", + )?; + let rows = stmt.query_map([], |row| Ok(row_to_run_entry(row).unwrap()))?; + let mut entries = Vec::new(); + for row in rows { + entries.push(row?); + } + Ok(entries) + } + + /// Copy the database to the given path for backup. + pub async fn backup(&self, dest: &Path) -> Result<()> { + let conn = self.conn.lock().await; + let mut dst = Connection::open(dest)?; + let backup = rusqlite::backup::Backup::new(&conn, &mut dst)?; + backup.run_to_completion(100, std::time::Duration::from_millis(10), None)?; + Ok(()) + } + + /// Export all runs as a JSON array. + pub async fn export_json(&self) -> Result { + let runs = self.list_runs().await?; + Ok(serde_json::to_string_pretty(&runs)?) + } +} + +fn migrate(conn: &Connection) -> Result<()> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER NOT NULL + );", + )?; + + let current: u32 = conn + .query_row( + "SELECT COALESCE(MAX(version), 0) FROM schema_version", + [], + |row| row.get(0), + ) + .unwrap_or(0); + + if current < 1 { + conn.execute_batch( + "CREATE TABLE runs ( + id TEXT PRIMARY KEY, + task TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'queued', + report_json TEXT, + error TEXT, + created_at TEXT NOT NULL, + completed_at TEXT + ); + + CREATE TABLE findings ( + id TEXT PRIMARY KEY, + run_id TEXT NOT NULL REFERENCES runs(id), + seq INTEGER NOT NULL, + title TEXT NOT NULL, + severity TEXT NOT NULL, + confidence REAL NOT NULL, + recommendation TEXT NOT NULL + ); + + CREATE INDEX idx_findings_run_id ON findings(run_id); + CREATE INDEX idx_runs_status ON runs(status); + CREATE INDEX idx_runs_created_at ON runs(created_at); + + INSERT INTO schema_version (version) VALUES (1);", + )?; + } + + assert!( + current <= SCHEMA_VERSION, + "database schema version {current} is newer than supported {SCHEMA_VERSION}" + ); + + Ok(()) +} + +fn status_to_str(status: &RunStatus) -> &'static str { + match status { + RunStatus::Queued => "queued", + RunStatus::Running => "running", + RunStatus::Completed => "completed", + RunStatus::Failed => "failed", + RunStatus::Cancelled => "cancelled", + } +} + +fn str_to_status(s: &str) -> RunStatus { + match s { + "queued" => RunStatus::Queued, + "running" => RunStatus::Running, + "completed" => RunStatus::Completed, + "failed" => RunStatus::Failed, + "cancelled" => RunStatus::Cancelled, + _ => RunStatus::Failed, + } +} + +fn row_to_run_entry(row: &rusqlite::Row) -> Result { + let id_str: String = row.get(0)?; + let report_json: Option = row.get(3)?; + Ok(RunEntry { + id: Uuid::parse_str(&id_str)?, + task: row.get(1)?, + status: str_to_status(&row.get::<_, String>(2)?), + report: report_json + .as_deref() + .filter(|s| !s.is_empty()) + .map(|s| serde_json::from_str(s)) + .transpose()?, + error: row.get(4)?, + created_at: row.get(5)?, + completed_at: row.get(6)?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_entry() -> RunEntry { + RunEntry { + id: Uuid::new_v4(), + task: "Test investigation".to_string(), + status: RunStatus::Queued, + report: None, + error: None, + created_at: "1700000000".to_string(), + completed_at: None, + } + } + + #[tokio::test] + async fn create_and_retrieve_run() { + let store = DataStore::open_in_memory().unwrap(); + let entry = sample_entry(); + store.insert_run(&entry).await.unwrap(); + + let loaded = store.get_run(entry.id).await.unwrap().unwrap(); + assert_eq!(loaded.id, entry.id); + assert_eq!(loaded.task, entry.task); + } + + #[tokio::test] + async fn update_run_status() { + let store = DataStore::open_in_memory().unwrap(); + let mut entry = sample_entry(); + store.insert_run(&entry).await.unwrap(); + + entry.status = RunStatus::Completed; + entry.completed_at = Some("1700000060".to_string()); + store.update_run(&entry).await.unwrap(); + + let loaded = store.get_run(entry.id).await.unwrap().unwrap(); + assert_eq!(loaded.status, RunStatus::Completed); + assert_eq!(loaded.completed_at.as_deref(), Some("1700000060")); + } + + #[tokio::test] + async fn list_runs_ordered_by_created_at() { + let store = DataStore::open_in_memory().unwrap(); + + let mut e1 = sample_entry(); + e1.created_at = "1700000001".to_string(); + store.insert_run(&e1).await.unwrap(); + + let mut e2 = sample_entry(); + e2.created_at = "1700000002".to_string(); + store.insert_run(&e2).await.unwrap(); + + let runs = store.list_runs().await.unwrap(); + assert_eq!(runs.len(), 2); + // Most recent first. + assert_eq!(runs[0].id, e2.id); + assert_eq!(runs[1].id, e1.id); + } + + #[tokio::test] + async fn get_nonexistent_run_returns_none() { + let store = DataStore::open_in_memory().unwrap(); + let result = store.get_run(Uuid::new_v4()).await.unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn export_json_produces_valid_array() { + let store = DataStore::open_in_memory().unwrap(); + let entry = sample_entry(); + store.insert_run(&entry).await.unwrap(); + + let json_str = store.export_json().await.unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); + assert!(parsed.is_array()); + assert_eq!(parsed.as_array().unwrap().len(), 1); + } + + #[tokio::test] + async fn migration_is_idempotent() { + let store = DataStore::open_in_memory().unwrap(); + // Running migrate again should succeed (schema already exists). + store.migrate_sync().unwrap(); + } +} diff --git a/api_server/src/lib.rs b/api_server/src/lib.rs new file mode 100644 index 0000000..b789168 --- /dev/null +++ b/api_server/src/lib.rs @@ -0,0 +1,19 @@ +pub mod data_store; +mod routes; +mod state; + +pub use data_store::DataStore; +pub use routes::build_router; +pub use state::{AppState, RunEntry, RunStatus, ServerConfig}; + +/// Start the API server on the given address. Blocks until shutdown. +pub async fn run_server(config: ServerConfig) -> anyhow::Result<()> { + let addr = std::net::SocketAddr::from((config.bind_addr, config.port)); + eprintln!("WraithRun API server listening on http://{addr}"); + eprintln!("API token: {}", config.api_token); + let state = AppState::new(config); + let app = build_router(state); + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app).await?; + Ok(()) +} diff --git a/api_server/src/routes.rs b/api_server/src/routes.rs new file mode 100644 index 0000000..f8c9a41 --- /dev/null +++ b/api_server/src/routes.rs @@ -0,0 +1,658 @@ +use axum::{ + extract::{Path, Request, State}, + http::StatusCode, + middleware::{self, Next}, + response::{Html, Response}, + routing::{get, post}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use tower_http::limit::RequestBodyLimitLayer; +use tower_http::trace::TraceLayer; +use uuid::Uuid; + +use core_engine::agent::Agent; +use cyber_tools::ToolRegistry; +use inference_bridge::{ModelConfig, OnnxVitisEngine}; + +use crate::state::{chrono_now, AppState, RunEntry, RunStatus}; + +/// Build the full application router with all v1 endpoints. +pub fn build_router(state: AppState) -> Router { + let body_limit = state.config.max_request_body_bytes; + + // Authenticated endpoints requiring Bearer token. + let authed = Router::new() + .route("/ready", get(ready)) + .route("/runs", post(create_run)) + .route("/runs", get(list_runs)) + .route("/runs/{id}", get(get_run)) + .route("/runs/{id}/cancel", post(cancel_run)) + .route("/runtime/status", get(runtime_status)) + .layer(middleware::from_fn_with_state( + state.clone(), + bearer_auth_middleware, + )); + + // Health is unauthenticated. + let api_v1 = Router::new() + .route("/health", get(health)) + .merge(authed); + + Router::new() + .route("/", get(dashboard)) + .nest("/api/v1", api_v1) + .layer(RequestBodyLimitLayer::new(body_limit)) + .layer(TraceLayer::new_for_http()) + .with_state(state) +} + +// --------------------------------------------------------------------------- +// Dashboard +// --------------------------------------------------------------------------- + +static DASHBOARD_HTML: &str = include_str!("dashboard.html"); + +async fn dashboard() -> Html<&'static str> { + Html(DASHBOARD_HTML) +} + +// --------------------------------------------------------------------------- +// Bearer token authentication middleware +// --------------------------------------------------------------------------- + +async fn bearer_auth_middleware( + State(state): State, + req: Request, + next: Next, +) -> Result)> { + let expected = &state.config.api_token; + + let auth_header = req + .headers() + .get("authorization") + .and_then(|v| v.to_str().ok()); + + match auth_header { + Some(header) if header.starts_with("Bearer ") => { + let token = &header[7..]; + if token == expected { + Ok(next.run(req).await) + } else { + tracing::warn!("API auth failure: invalid bearer token"); + Err(( + StatusCode::UNAUTHORIZED, + Json(ErrorResponse { + error: "invalid bearer token".to_string(), + }), + )) + } + } + _ => { + tracing::warn!("API auth failure: missing or malformed Authorization header"); + Err(( + StatusCode::UNAUTHORIZED, + Json(ErrorResponse { + error: "missing or malformed Authorization header".to_string(), + }), + )) + } + } +} + +// --------------------------------------------------------------------------- +// Health & readiness +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +struct HealthResponse { + status: &'static str, + version: &'static str, + uptime_secs: u64, +} + +async fn health(State(state): State) -> Json { + let started: u64 = state.started_at.parse().unwrap_or(0); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + Json(HealthResponse { + status: "ok", + version: env!("CARGO_PKG_VERSION"), + uptime_secs: now.saturating_sub(started), + }) +} + +#[derive(Serialize)] +struct ReadyResponse { + ready: bool, + tools_available: usize, +} + +async fn ready() -> Json { + let registry = ToolRegistry::with_default_tools(); + Json(ReadyResponse { + ready: true, + tools_available: registry.tool_names().len(), + }) +} + +// --------------------------------------------------------------------------- +// Run management +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +struct CreateRunRequest { + task: String, + #[serde(default = "default_max_steps")] + max_steps: usize, +} + +fn default_max_steps() -> usize { + 8 +} + +#[derive(Serialize)] +struct CreateRunResponse { + id: Uuid, + status: RunStatus, +} + +async fn create_run( + State(state): State, + Json(body): Json, +) -> Result<(StatusCode, Json), (StatusCode, Json)> { + let task = body.task.trim().to_string(); + if task.is_empty() { + return Err(( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "task must not be empty".to_string(), + }), + )); + } + + // Enforce concurrency limit. + { + let count = state.active_run_count.lock().await; + if *count >= state.config.max_concurrent_runs { + return Err(( + StatusCode::TOO_MANY_REQUESTS, + Json(ErrorResponse { + error: format!( + "max concurrent runs ({}) reached", + state.config.max_concurrent_runs + ), + }), + )); + } + } + + let run_id = Uuid::new_v4(); + let entry = RunEntry { + id: run_id, + task: task.clone(), + status: RunStatus::Queued, + report: None, + error: None, + created_at: chrono_now(), + completed_at: None, + }; + + // Persist to database if available. + if let Some(db) = &state.db { + let _ = db.insert_run(&entry).await; + } + + { + let mut runs = state.runs.write().await; + runs.insert(run_id, entry); + } + + // Spawn background task to execute the investigation. + let state_clone = state.clone(); + let max_steps = body.max_steps; + tokio::spawn(async move { + execute_run(state_clone, run_id, task, max_steps).await; + }); + + Ok(( + StatusCode::ACCEPTED, + Json(CreateRunResponse { + id: run_id, + status: RunStatus::Queued, + }), + )) +} + +async fn execute_run(state: AppState, run_id: Uuid, task: String, max_steps: usize) { + // Mark as running. + { + let mut runs = state.runs.write().await; + if let Some(entry) = runs.get_mut(&run_id) { + entry.status = RunStatus::Running; + } + } + { + let mut count = state.active_run_count.lock().await; + *count += 1; + } + + let result = run_investigation(&task, max_steps).await; + + // Decrement active count. + { + let mut count = state.active_run_count.lock().await; + *count = count.saturating_sub(1); + } + + // Store result. + let mut runs = state.runs.write().await; + if let Some(entry) = runs.get_mut(&run_id) { + // Don't overwrite if cancelled. + if entry.status == RunStatus::Cancelled { + return; + } + match result { + Ok(report) => { + entry.status = RunStatus::Completed; + entry.report = Some(report); + } + Err(e) => { + entry.status = RunStatus::Failed; + entry.error = Some(e.to_string()); + } + } + entry.completed_at = Some(chrono_now()); + + // Persist to database if available. + if let Some(db) = &state.db { + let _ = db.update_run(entry).await; + } + } +} + +async fn run_investigation(task: &str, max_steps: usize) -> anyhow::Result { + let model_config = ModelConfig { + model_path: std::path::PathBuf::from("./models/llm.onnx"), + tokenizer_path: None, + max_new_tokens: 256, + temperature: 0.2, + dry_run: true, + vitis_config: None, + }; + let engine = OnnxVitisEngine::new(model_config); + let tools = ToolRegistry::with_default_tools(); + let agent = Agent::new(engine, tools).with_max_steps(max_steps); + agent.run(task).await +} + +#[derive(Serialize)] +struct RunListEntry { + id: Uuid, + task: String, + status: RunStatus, + created_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + completed_at: Option, +} + +async fn list_runs(State(state): State) -> Json> { + let runs = state.runs.read().await; + let mut entries: Vec = runs + .values() + .map(|entry| RunListEntry { + id: entry.id, + task: entry.task.clone(), + status: entry.status.clone(), + created_at: entry.created_at.clone(), + completed_at: entry.completed_at.clone(), + }) + .collect(); + entries.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + Json(entries) +} + +async fn get_run( + State(state): State, + Path(id): Path, +) -> Result, (StatusCode, Json)> { + let runs = state.runs.read().await; + match runs.get(&id) { + Some(entry) => Ok(Json(entry.clone())), + None => Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!("run {id} not found"), + }), + )), + } +} + +async fn cancel_run( + State(state): State, + Path(id): Path, +) -> Result, (StatusCode, Json)> { + let mut runs = state.runs.write().await; + match runs.get_mut(&id) { + Some(entry) => { + if entry.status == RunStatus::Queued || entry.status == RunStatus::Running { + entry.status = RunStatus::Cancelled; + entry.completed_at = Some(chrono_now()); + } + Ok(Json(entry.clone())) + } + None => Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!("run {id} not found"), + }), + )), + } +} + +// --------------------------------------------------------------------------- +// Runtime status +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +struct RuntimeStatusResponse { + mode: &'static str, + tools_available: Vec, + max_concurrent_runs: usize, +} + +async fn runtime_status(State(state): State) -> Json { + let registry = ToolRegistry::with_default_tools(); + Json(RuntimeStatusResponse { + mode: "dry-run", + tools_available: registry.tool_names(), + max_concurrent_runs: state.config.max_concurrent_runs, + }) +} + +// --------------------------------------------------------------------------- +// Error envelope +// --------------------------------------------------------------------------- + +#[derive(Serialize, Deserialize)] +struct ErrorResponse { + error: String, +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::ServerConfig; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use tower::ServiceExt; + + const TEST_TOKEN: &str = "test-secret-token"; + + fn test_state() -> AppState { + let mut config = ServerConfig::default(); + config.api_token = TEST_TOKEN.to_string(); + AppState::new(config) + } + + fn auth_header() -> (&'static str, String) { + ("authorization", format!("Bearer {TEST_TOKEN}")) + } + + #[tokio::test] + async fn dashboard_returns_html() { + let app = build_router(test_state()); + let req = Request::builder() + .uri("/") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let ct = resp.headers().get("content-type").unwrap().to_str().unwrap(); + assert!(ct.contains("html")); + } + + #[tokio::test] + async fn health_endpoint_returns_ok_without_auth() { + let app = build_router(test_state()); + let req = Request::builder() + .uri("/api/v1/health") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let body = axum::body::to_bytes(resp.into_body(), 4096) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["status"], "ok"); + } + + #[tokio::test] + async fn authenticated_endpoint_rejects_missing_token() { + let app = build_router(test_state()); + let req = Request::builder() + .uri("/api/v1/ready") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn authenticated_endpoint_rejects_wrong_token() { + let app = build_router(test_state()); + let req = Request::builder() + .uri("/api/v1/ready") + .header("authorization", "Bearer wrong-token") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn ready_endpoint_returns_tools() { + let (key, val) = auth_header(); + let app = build_router(test_state()); + let req = Request::builder() + .uri("/api/v1/ready") + .header(key, val) + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let body = axum::body::to_bytes(resp.into_body(), 4096) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["ready"], true); + assert!(json["tools_available"].as_u64().unwrap() > 0); + } + + #[tokio::test] + async fn create_run_rejects_empty_task() { + let (key, val) = auth_header(); + let app = build_router(test_state()); + let req = Request::builder() + .method("POST") + .uri("/api/v1/runs") + .header("content-type", "application/json") + .header(key, val) + .body(Body::from(r#"{"task":""}"#)) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn create_run_accepts_valid_task() { + let (key, val) = auth_header(); + let app = build_router(test_state()); + let req = Request::builder() + .method("POST") + .uri("/api/v1/runs") + .header("content-type", "application/json") + .header(key, val) + .body(Body::from( + r#"{"task":"Investigate unauthorized SSH keys"}"#, + )) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::ACCEPTED); + + let body = axum::body::to_bytes(resp.into_body(), 4096) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["status"], "queued"); + assert!(json["id"].as_str().is_some()); + } + + #[tokio::test] + async fn list_runs_returns_array() { + let (key, val) = auth_header(); + let app = build_router(test_state()); + let req = Request::builder() + .uri("/api/v1/runs") + .header(key, val) + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let body = axum::body::to_bytes(resp.into_body(), 4096) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!(json.is_array()); + } + + #[tokio::test] + async fn get_run_returns_not_found_for_unknown_id() { + let (key, val) = auth_header(); + let app = build_router(test_state()); + let fake_id = Uuid::new_v4(); + let req = Request::builder() + .uri(&format!("/api/v1/runs/{fake_id}")) + .header(key, val) + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn cancel_run_returns_not_found_for_unknown_id() { + let (key, val) = auth_header(); + let app = build_router(test_state()); + let fake_id = Uuid::new_v4(); + let req = Request::builder() + .method("POST") + .uri(&format!("/api/v1/runs/{fake_id}/cancel")) + .header(key, val) + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn runtime_status_returns_tools() { + let (key, val) = auth_header(); + let app = build_router(test_state()); + let req = Request::builder() + .uri("/api/v1/runtime/status") + .header(key, val) + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let body = axum::body::to_bytes(resp.into_body(), 4096) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["mode"], "dry-run"); + assert!(json["tools_available"].as_array().unwrap().len() > 0); + } + + #[tokio::test] + async fn create_and_retrieve_run() { + let state = test_state(); + let app = build_router(state.clone()); + let (key, val) = auth_header(); + + // Create a run. + let req = Request::builder() + .method("POST") + .uri("/api/v1/runs") + .header("content-type", "application/json") + .header(key, &val) + .body(Body::from( + r#"{"task":"Investigate unauthorized SSH keys"}"#, + )) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + let body = axum::body::to_bytes(resp.into_body(), 4096) + .await + .unwrap(); + let create_json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let run_id = create_json["id"].as_str().unwrap(); + + // Give the background task a moment to start. + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + // Retrieve it. + let app2 = build_router(state); + let (key2, val2) = auth_header(); + let req = Request::builder() + .uri(&format!("/api/v1/runs/{run_id}")) + .header(key2, val2) + .body(Body::empty()) + .unwrap(); + let resp = app2.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let body = axum::body::to_bytes(resp.into_body(), 65536) + .await + .unwrap(); + let run_json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(run_json["id"], run_id); + assert_eq!(run_json["task"], "Investigate unauthorized SSH keys"); + } + + #[tokio::test] + async fn concurrency_limit_rejects_excess_runs() { + let mut config = ServerConfig::default(); + config.max_concurrent_runs = 1; + config.api_token = TEST_TOKEN.to_string(); + let state = AppState::new(config); + + // Manually occupy one slot. + { + let mut count = state.active_run_count.lock().await; + *count = 1; + } + + let (key, val) = auth_header(); + let app = build_router(state); + let req = Request::builder() + .method("POST") + .uri("/api/v1/runs") + .header("content-type", "application/json") + .header(key, val) + .body(Body::from(r#"{"task":"test"}"#)) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS); + } +} diff --git a/api_server/src/state.rs b/api_server/src/state.rs new file mode 100644 index 0000000..270fb20 --- /dev/null +++ b/api_server/src/state.rs @@ -0,0 +1,103 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::{Mutex, RwLock}; +use uuid::Uuid; + +use core_engine::RunReport; +use serde::{Deserialize, Serialize}; + +use crate::data_store::DataStore; + +/// Server configuration for `wraithrun serve`. +#[derive(Debug, Clone)] +pub struct ServerConfig { + /// Port to listen on. Default: 8080. + pub port: u16, + /// Bind address. Always 127.0.0.1 for security. + pub bind_addr: [u8; 4], + /// Maximum concurrent runs allowed. + pub max_concurrent_runs: usize, + /// Bearer token for API authentication. Auto-generated if not provided. + pub api_token: String, + /// Maximum request body size in bytes. Default: 1 MiB. + pub max_request_body_bytes: usize, + /// Path to the SQLite database file. If None, uses in-memory storage only. + pub database_path: Option, +} + +impl Default for ServerConfig { + fn default() -> Self { + Self { + port: 8080, + bind_addr: [127, 0, 0, 1], + max_concurrent_runs: 4, + api_token: Uuid::new_v4().to_string(), + max_request_body_bytes: 1_048_576, + database_path: None, + } + } +} + +/// Status of an investigation run. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RunStatus { + Queued, + Running, + Completed, + Failed, + Cancelled, +} + +/// A tracked investigation run. +#[derive(Debug, Clone, Serialize)] +pub struct RunEntry { + pub id: Uuid, + pub task: String, + pub status: RunStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub report: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + pub created_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub completed_at: Option, +} + +/// Shared application state visible to all route handlers. +#[derive(Clone)] +pub struct AppState { + pub runs: Arc>>, + pub active_run_count: Arc>, + pub config: ServerConfig, + pub started_at: String, + pub db: Option, +} + +impl AppState { + pub fn new(config: ServerConfig) -> Self { + let db = config.database_path.as_ref().map(|path| { + DataStore::open(path).expect("failed to open database") + }); + Self { + runs: Arc::new(RwLock::new(HashMap::new())), + active_run_count: Arc::new(Mutex::new(0)), + config, + started_at: chrono_now(), + db, + } + } +} + +/// Simple ISO-8601 timestamp without pulling in chrono. +pub fn chrono_now() -> String { + // Use std SystemTime for a dependency-free timestamp. + let now = std::time::SystemTime::now(); + let duration = now + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + let secs = duration.as_secs(); + // Basic UTC timestamp: seconds since epoch formatted. + format!("{secs}") +} diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 1dcc53d..9059885 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -22,4 +22,5 @@ tracing-subscriber.workspace = true tar.workspace = true core_engine = { path = "../core_engine" } cyber_tools = { path = "../cyber_tools" } -inference_bridge = { path = "../inference_bridge" } \ No newline at end of file +inference_bridge = { path = "../inference_bridge" } +api_server = { path = "../api_server" } \ No newline at end of file diff --git a/cli/src/main.rs b/cli/src/main.rs index f4869ca..f78f9d2 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -488,6 +488,7 @@ use inference_bridge::{probe_model_capability, ModelConfig, OnnxVitisEngine, Vit use serde::{Deserialize, Serialize}; use serde_json::Value; use sha2::{Digest, Sha256}; +use api_server::{run_server, ServerConfig}; use tracing_subscriber::EnvFilter; const DEFAULT_CONFIG_FILE: &str = "wraithrun.toml"; @@ -601,7 +602,7 @@ impl CapabilityOverride { #[derive(Debug, Parser, Clone)] #[command(name = "wraithrun", about = "Local-first cyber investigation runtime")] struct Cli { - #[arg(long, required_unless_present_any = ["task_file", "task_stdin", "task_template", "doctor", "list_profiles", "list_tools", "describe_tool", "print_effective_config", "init_config", "explain_effective_config", "list_task_templates", "verify_bundle", "live_setup", "models_list", "models_validate", "models_benchmark"])] + #[arg(long, required_unless_present_any = ["task_file", "task_stdin", "task_template", "doctor", "list_profiles", "list_tools", "describe_tool", "print_effective_config", "init_config", "explain_effective_config", "list_task_templates", "verify_bundle", "live_setup", "models_list", "models_validate", "models_benchmark", "serve"])] task: Option, #[arg(long, value_name = "PATH", conflicts_with_all = ["task", "task_stdin", "task_template"])] @@ -670,6 +671,18 @@ struct Cli { #[arg(long)] models_benchmark: bool, + #[arg(long)] + serve: bool, + + #[arg(long, default_value_t = 8080, requires = "serve")] + port: u16, + + #[arg(long, requires = "serve")] + api_token: Option, + + #[arg(long, value_name = "PATH", requires = "serve")] + database: Option, + #[arg(long)] config: Option, @@ -1362,6 +1375,7 @@ impl DoctorReport { async fn main() -> Result<()> { let cli_args = normalize_models_alias(std::env::args_os()); let cli_args = normalize_live_setup_alias(cli_args); + let cli_args = normalize_serve_alias(cli_args); let cli = Cli::parse_from(cli_args); ensure_exclusive_modes(&cli)?; ensure_introspection_format_usage(&cli)?; @@ -1408,6 +1422,21 @@ async fn main() -> Result<()> { return Ok(()); } + if cli.serve { + let mut config = ServerConfig { + port: cli.port, + ..ServerConfig::default() + }; + if let Some(token) = &cli.api_token { + config.api_token = token.clone(); + } + if let Some(db_path) = &cli.database { + config.database_path = Some(db_path.clone()); + } + run_server(config).await?; + return Ok(()); + } + if cli.live_setup { let message = run_live_setup(&cli)?; println!("{message}"); @@ -1570,6 +1599,28 @@ fn normalize_live_setup_alias(args: impl IntoIterator) -> Vec) -> Vec { + let args: Vec = args.into_iter().collect(); + if args.len() < 2 { + return args; + } + + let is_serve = args + .get(1) + .and_then(|value| value.to_str()) + .map(|value| value.eq_ignore_ascii_case("serve")) + .unwrap_or(false); + if !is_serve { + return args; + } + + let mut normalized = Vec::with_capacity(args.len()); + normalized.push(args[0].clone()); + normalized.push(OsString::from("--serve")); + normalized.extend(args.into_iter().skip(2)); + normalized +} + fn resolve_runtime_config(cli: &Cli) -> Result { let task = resolve_task_for_run(cli)?; @@ -2968,6 +3019,9 @@ fn ensure_exclusive_modes(cli: &Cli) -> Result<()> { if cli.models_benchmark { selected.push("--models-benchmark"); } + if cli.serve { + selected.push("--serve"); + } if selected.len() > 1 { bail!( @@ -5626,6 +5680,10 @@ mod tests { models_list: false, models_validate: false, models_benchmark: false, + serve: false, + port: 8080, + api_token: None, + database: None, config: None, profile: None, model: None, diff --git a/docs/cli-reference.md b/docs/cli-reference.md index aaa20f2..756f6b0 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -11,6 +11,7 @@ wraithrun [OPTIONS] --task wraithrun [OPTIONS] --task-stdin wraithrun [OPTIONS] --task-file wraithrun [OPTIONS] --task-template +wraithrun serve [--port ] [--api-token ] [--database ] wraithrun --doctor [OPTIONS] wraithrun --list-task-templates wraithrun --list-tools [OPTIONS] @@ -43,6 +44,10 @@ wraithrun models benchmark [OPTIONS] - `--models-list`: list discovered live model packs and preset tuning (`wraithrun models list`). - `--models-validate`: run live model-pack readiness checks for discovered packs (`wraithrun models validate`). - `--models-benchmark`: rank discovered live packs by estimated responsiveness (`wraithrun models benchmark`). +- `--serve`: start the local API server and web dashboard. Alias: `wraithrun serve`. +- `--port `: port for the API server. Default: `8080`. Requires `--serve`. +- `--api-token `: bearer token for API authentication. Auto-generated if omitted. Requires `--serve`. +- `--database `: SQLite database file for persistent run storage. In-memory if omitted. Requires `--serve`. - `--verify-bundle `: verify evidence bundle file integrity from a bundle directory or direct `SHA256SUMS` path. - `--introspection-format `: format for introspection modes. Values: `text`, `json`. Default: `text`. - `--print-effective-config`: print resolved runtime settings as JSON and exit.