diff --git a/README.md b/README.md new file mode 100644 index 0000000..f3d4194 --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# rust-link + +The **sidecar** half of the Runic Gateway bridge for [Rust](https://rust.facepunch.com/). It +terminates the loopback link from a Rust server's Oxide bridge plugin and exposes the WebSocket + +REST surface the website consumes. + +It is the mirror of [`RunicGateway/link`](https://gitea.whitlocktech.com/RunicGateway/link), which +does the same job for Ultima Online, and it keeps that bridge's central invariant unchanged: + +> **The game server is never reachable from the website.** The plugin dials *out* to this process; +> this process owns the listener. Only the sidecar is exposed, and only the website's backend talks +> to it. + +``` +Rust server + Oxide (RunicGateway/Rust-Plugins, C#) + │ loopback TCP 127.0.0.1:7799, newline-delimited JSON, bidirectional + │ the PLUGIN dials out (the game opens no listening port for us) + ▼ +rust-link sidecar (this repo, Rust) ← the only network-facing bridge component + │ WebSocket (live feed) + REST (point-in-time reads), bearer-token auth + ▼ +website backend + module-rust (RunicGateway/Module-Rust, Node) +``` + +## One server, one sidecar + +This binary serves **exactly one** Rust game server. A community running six servers runs six +pairs, each with its own port, database and token; `module-rust` holds six clients and the website +core never learns there is more than one. Nothing here is multiplexed, and nothing here should +become multiplexed. + +## Build and run + +```bash +cd sidecar +cargo build --release # → target/release/rust-link-sidecar +cargo run # info logging; writes sidecar.toml (with a generated token) on first run +RUST_LOG=debug cargo run # verbose, including heartbeats +``` + +Everything is configured from `sidecar.toml` — nothing is compiled into the binary. **Auth is +always on**: a blank token is generated and written back on first start, so there is no state in +which this process listens without one. `--print-config` resolves the configuration and prints it +as JSON, which is how an installer reads the token back without scraping a log. + +See [`sidecar/README.md`](sidecar/README.md) for the configuration reference and the endpoint list. + +## The protocol is a contract + +The loopback JSON protocol (plugin ↔ sidecar) and this sidecar's HTTP/WS API (sidecar ↔ website) +are **versioned compatibility contracts**, not build dependencies. `PROTOCOL_VERSION` lives in +[`sidecar/src/main.rs`](sidecar/src/main.rs); every response carries `X-RustLink-Version`, and a +client that declares a different one is refused `409` rather than served something it will +mis-parse. + +A version is declared in **three** places and they must agree: + +| Where | Repo | +|---|---| +| `PROTOCOL_VERSION` | this repo | +| `overlay.toml` | [`RunicGateway/Rust-Plugins`](https://gitea.whitlocktech.com/RunicGateway/Rust-Plugins) | +| `module.json` | [`RunicGateway/Module-Rust`](https://gitea.whitlocktech.com/RunicGateway/Module-Rust) | + +The canonical spec is +[`docs/rust-link/`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/rust-link). If +you add or change an event or a command, update all three repos **and** the spec in the same +change. + +## Licence + +GPL-3.0-or-later. See [LICENSE.md](LICENSE.md). diff --git a/sidecar/Cargo.lock b/sidecar/Cargo.lock new file mode 100644 index 0000000..5629d0f --- /dev/null +++ b/sidecar/Cargo.lock @@ -0,0 +1,2277 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "base64", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[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.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[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 = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[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", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +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.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +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 = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6480ccc157a1389bb2e4891b24751b0f798ba640d22386f23143fbcc89da195a" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.9.4", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[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", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[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" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rust-link-sidecar" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "chrono", + "getrandom", + "serde", + "serde_json", + "sqlx", + "tokio", + "toml", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +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 = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.119", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.20", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.20", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.20", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +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-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", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[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.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +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", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +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", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[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_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[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_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[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_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/sidecar/Cargo.toml b/sidecar/Cargo.toml new file mode 100644 index 0000000..8ce5685 --- /dev/null +++ b/sidecar/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "rust-link-sidecar" +version = "0.1.0" +edition = "2021" +license = "GPL-3.0-or-later" +description = "Rust sidecar for the rust-link bridge: terminates the loopback link to a Rust/Oxide game server and exposes WebSocket + REST to the website." + +[dependencies] +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time", "signal"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +anyhow = "1" +axum = { version = "0.7", features = ["ws"] } +sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] } +toml = "0.8" +getrandom = "0.2" +chrono = { version = "0.4", default-features = false, features = ["std", "clock"] } + +[profile.release] +opt-level = 2 diff --git a/sidecar/README.md b/sidecar/README.md new file mode 100644 index 0000000..ce85119 --- /dev/null +++ b/sidecar/README.md @@ -0,0 +1,92 @@ +# rust-link-sidecar + +Configuration reference and endpoint list. For what this component *is*, see the +[repo README](../README.md). + +## Configuration + +`sidecar.toml`, resolved in this order: `--config `, else `$RUSTLINK_CONFIG`, else +`./sidecar.toml`. Environment variables override the file; the file overrides the defaults. + +| Key | Env | Default | What it is | +|---|---|---|---| +| `[game].bind` | `RUSTLINK_GAME_BIND` | `127.0.0.1:7799` | Where the Oxide plugin dials in | +| `[game].server_id` | `RUSTLINK_SERVER_ID` | *(empty)* | Optional cross-check against the plugin's own `serverId` | +| `[web].bind` | `RUSTLINK_WEB_BIND` | `127.0.0.1:8090` | Where the website reaches this sidecar | +| `[web].auth_token` | `RUSTLINK_WEB_TOKEN` | *(generated)* | The shared secret the website presents | +| `[store].path` | `RUSTLINK_DB_PATH` | `rust-link.db` | SQLite file | + +Two things about those defaults are load-bearing: + +- **`[game].bind` is loopback, and there is no token on that link.** The plugin and the sidecar + share a host; `127.0.0.1` *is* the authentication. Binding it to a routable address puts an + unauthenticated command channel on the network. +- **A relative `[store].path` resolves against the directory holding `sidecar.toml`**, not the + working directory. A service manager's working directory must not decide where the database + lands — on Windows that can be `%SystemRoot%\System32`, or a silently redirected VirtualStore + copy. + +`[game].server_id` is a **cross-check, not a second source of truth**. The plugin announces its own +`serverId` and that is the authority; when both are set and they disagree, the sidecar logs the +disagreement loudly and keeps the plugin's. Two game servers pointed at one sidecar by a copied +config is the mistake this catches, and it is silent in every other design. + +### Reading the token back + +```bash +rust-link-sidecar --print-config +``` + +Resolves the configuration exactly as a normal start would — writing the file and generating the +token if they are missing — and prints it as JSON on stdout, **including the token in clear text**. +That is the supported way for an installer to obtain it; the alternative is scraping a log. + +## Endpoints + +Everything except `/health` requires the token, as `Authorization: Bearer `, `X-Api-Key: `, +or `?token=` (the last so browser WebSocket clients, which cannot set handshake headers, can +still authenticate). Every response carries `X-RustLink-Version`. + +| Route | Backed by | Notes | +|---|---|---| +| `GET /health` | — | Unauthenticated, so monitoring can reach it | +| `GET /server` | store | The last `server.hello`. **`204` when the game has never connected** | +| `GET /events?kind=&limit=` | store | Newest first; `limit` clamped to 1–1000 | +| `GET /status` | plugin (RPC) | A live round trip. `503` with no plugin, `504` on no reply | +| `GET /ws` | broadcast | The live feed. Sends `ws.hello` on connect | + +The split is the point: the store-backed reads answer while the game is off, which is what lets the +website render a server list during a wipe or a restart. `/status` is the one route that fails when +the game is down, because "what is it doing right now" has no stale answer worth giving. + +`/server` answers `204`, not `200` with a null, when the game has never connected. "We have never +heard from this server" and "this server reports nothing" are different answers, and a client that +cannot tell them apart renders a server that does not exist. + +## Protocol 1 + +Newline-delimited JSON over TCP, both directions. Outbound frames (plugin → sidecar) carry `kind`; +inbound frames (sidecar → plugin) carry `cmd`. Lines are capped at 1 MiB; an over-long line is +discarded and the connection stays up. + +| Frame | Direction | Purpose | +|---|---|---| +| `server.hello` | plugin → sidecar | Sent on **every connect**, not once at server start — this process restarts independently of the game. Carries `serverId` and `bootId` | +| `ping` / `pong` | sidecar → plugin → sidecar | The heartbeat, every 30s. A `pong` is never persisted; it only moves `last_event` | +| `server.status` | sidecar → plugin → sidecar | The one request/reply verb, correlated by `reqId` | + +`bootId` is how a game restart is told apart from a sidecar reconnect — the distinction the event +system's `reconcile` hangs off later. + +**The RPC reply timeout (`rpc::REPLY_TIMEOUT`, 10s) is a ceiling every later command budget sits +under.** Core classifies a budget overrun as retryable unconditionally, because it cannot ask the +game while the action is still awaiting a socket. An action whose `budgetMs` exceeds this can never +report `retry: false`. + +## Checks + +```bash +cargo fmt --all -- --check +cargo clippy --all-targets -- -D warnings +cargo test +``` diff --git a/sidecar/src/app.rs b/sidecar/src/app.rs new file mode 100644 index 0000000..a834788 --- /dev/null +++ b/sidecar/src/app.rs @@ -0,0 +1,219 @@ +//! The sidecar itself: everything that happens between "we have a config path" and "we were told +//! to stop". +//! +//! [`run`] is parameterised on the two things a supervisor cares about: +//! +//! - `ready` is called once the sidecar is actually up (listener bound, store open). A service +//! wrapper reports `Running` to its host there, so a config or bind failure surfaces as a *start* +//! failure rather than a service that reports Running and then dies. +//! - `shutdown` is whatever "stop" means on this host. +//! +//! Phase 1 runs in the foreground only; the parameters exist now so that adding a host's own +//! supervision later is a new module rather than a restructuring of this one. + +use std::future::Future; +use std::sync::atomic::AtomicI64; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use tokio::sync::{broadcast, mpsc}; +use tracing::{info, warn}; + +use crate::{config, game, rpc, store, web}; + +/// How often the sidecar pings the plugin. +/// +/// This is the only thing that moves `last_event` on a quiet server, and a Rust server with nobody +/// on it is very quiet. Without it, "the game has said nothing for six hours" would be +/// indistinguishable from "the link died six hours ago". +const HEARTBEAT: Duration = Duration::from_secs(30); + +/// Runs the sidecar until `shutdown` resolves. +/// +/// `config_path` is the `--config` argument, or `None` to resolve `$RUSTLINK_CONFIG` and the +/// default as usual. +pub async fn run(config_path: Option<&str>, ready: R, shutdown: S) -> anyhow::Result<()> +where + R: FnOnce(), + S: Future, +{ + info!("rust-link sidecar starting"); + + let loaded = config::Config::load(config_path)?; + let cfg = loaded.cfg; + info!( + config = %loaded.path.display(), + game = %cfg.game.bind, + web = %cfg.web.bind, + db = %cfg.store.path, + auth = cfg.auth_required(), + "configuration loaded" + ); + + // Game link: events in, commands out. + let (event_tx, mut event_rx) = mpsc::unbounded_channel::(); + let (handle, _bound) = game::serve(&cfg.game.bind, event_tx).await?; + + // Live feed: every game event fans out to all connected website WebSocket clients. + let (bcast_tx, _) = broadcast::channel::(1024); + + // Request/reply correlation for REST queries. + let rpc = rpc::Rpc::new(); + + // Durable store: event history and the server board. + let store = store::Store::open(&cfg.store.path).await?; + + // Health/observability state. + let started = Instant::now(); + let last_event = Arc::new(AtomicI64::new(0)); + + // Website-facing HTTP server. + let web_state = web::AppState { + events: bcast_tx.clone(), + game: handle.clone(), + rpc: rpc.clone(), + store: store.clone(), + token: Arc::new(cfg.web.auth_token.clone()), + started, + last_event: last_event.clone(), + }; + let web_bind = cfg.web.bind.clone(); + tokio::spawn(async move { + if let Err(e) = web::serve(&web_bind, web_state).await { + tracing::error!(error = %e, "web server exited"); + } + }); + + // The heartbeat. Commands to a disconnected plugin are dropped rather than queued, so this is + // safe to fire whether or not anything is connected. + let ping_handle = handle.clone(); + tokio::spawn(async move { + let mut tick = tokio::time::interval(HEARTBEAT); + // The first tick fires immediately, which would ping before the plugin has had a chance to + // dial in and log a dropped command on every start. + tick.tick().await; + loop { + tick.tick().await; + if ping_handle.is_connected().await { + ping_handle.send(r#"{"cmd":"ping"}"#.to_string()).await; + } + } + }); + + // The event loop. A line that correlates to a pending REST call is a reply — route it to the + // waiting caller and stop. Everything else is a live event: persist it, then broadcast it. + let configured_server_id = cfg.game.server_id.clone(); + let feed_tx = bcast_tx.clone(); + let route_rpc = rpc.clone(); + let event_store = store.clone(); + let last_event_ts = last_event.clone(); + tokio::spawn(async move { + let mut total: u64 = 0; + + while let Some(ev) = event_rx.recv().await { + // Any line from the plugin — a pong included — is a sign of life. + last_event_ts.store(now_ms(), std::sync::atomic::Ordering::Relaxed); + + if route_rpc.try_route(&ev.value).await { + continue; // consumed as a reply + } + + total += 1; + let line = ev.value.to_string(); + + match ev.kind.as_str() { + "server.hello" => { + check_server_id(&configured_server_id, &ev.value); + info!(kind = %ev.kind, n = total, "{}", line); + if let Err(e) = event_store + .put_server_state(frame_t(&ev.value), &line) + .await + { + warn!(error = %e, "server board write failed"); + } + } + "link.down" => info!("game link down"), + _ => tracing::debug!(kind = %ev.kind, n = total, "{}", line), + } + + // `pong` and `link.down` are ephemeral: one is heartbeat chatter and the other is this + // process's own observation, not something the game said. Neither is history. + if ev.kind != "pong" && ev.kind != "link.down" { + if let Err(e) = event_store + .insert_event(frame_t(&ev.value), &ev.kind, &line) + .await + { + warn!(error = %e, "event persist failed"); + } + } + + // Broadcast after persisting, so a client that reacts by reading the store cannot + // beat its own event there. `send` fails only when nobody is subscribed. + let _ = feed_tx.send(line); + } + }); + + ready(); + info!("rust-link sidecar ready"); + + shutdown.await; + info!("shutting down"); + Ok(()) +} + +/// The plugin's `serverId` is the authority; `[game].server_id` is a cross-check. A disagreement is +/// almost always two game servers pointed at one sidecar by a copied config, which is silent in +/// every other design and produces one server's history under another's name. +fn check_server_id(configured: &str, hello: &serde_json::Value) { + if configured.is_empty() { + return; + } + let announced = hello.get("serverId").and_then(|v| v.as_str()).unwrap_or(""); + if !announced.is_empty() && announced != configured { + warn!( + configured, + announced, + "the connected plugin announces a different serverId than this sidecar is configured \ + for; keeping the plugin's. Two servers sharing one sidecar is the usual cause." + ); + } +} + +/// A frame's own timestamp, falling back to ours. The plugin stamps `t` at the moment the world was +/// read, which is earlier than the moment we saw the line and is the one worth keeping. +fn frame_t(value: &serde_json::Value) -> i64 { + value + .get("t") + .and_then(|v| v.as_i64()) + .unwrap_or_else(now_ms) +} + +fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn a_frames_own_timestamp_wins_over_ours() { + assert_eq!( + frame_t(&json!({"t": 1_700_000_000_000i64})), + 1_700_000_000_000 + ); + } + + /// A frame with no `t` must still land with a usable timestamp rather than at the epoch, or + /// every un-stamped event sorts to the beginning of history forever. + #[test] + fn a_frame_without_a_timestamp_gets_one() { + assert!(frame_t(&json!({"kind": "x"})) > 1_600_000_000_000); + // A non-numeric `t` is a malformed frame, not a zero. + assert!(frame_t(&json!({"t": "nope"})) > 1_600_000_000_000); + } +} diff --git a/sidecar/src/cli.rs b/sidecar/src/cli.rs new file mode 100644 index 0000000..dda7de6 --- /dev/null +++ b/sidecar/src/cli.rs @@ -0,0 +1,170 @@ +//! Command-line surface. +//! +//! The sidecar is configured by file and environment (see [`crate::config`]); this is deliberately +//! not a second configuration mechanism. It exists so the binary can be *driven by an installer* +//! rather than only by a human reading its logs: +//! +//! - `--print-config` resolves the configuration exactly as a normal start would — including +//! generating the auth token on first run — and prints it as JSON on stdout. That is the +//! supported way to obtain the token for the website's Admin -> Modules -> Rust form. +//! - `--config ` names the config file without having to export `$RUSTLINK_CONFIG`, so a +//! diagnostic run can point at an installed config from any working directory. +//! +//! Hand-rolled rather than pulled from a crate: four flags, no subcommands, no completions. + +/// What this invocation should do. Everything except `Run` prints and exits. +#[derive(Debug, PartialEq, Eq)] +pub enum Mode { + /// Normal operation: bind the game listener and the web server. + Run, + /// Resolve config, print it as JSON, exit. + PrintConfig, + Help, + Version, +} + +#[derive(Debug, PartialEq, Eq)] +pub struct Cli { + pub mode: Mode, + /// `--config `, which outranks `$RUSTLINK_CONFIG`. + pub config: Option, +} + +pub const USAGE: &str = "\ +rust-link sidecar — bridges one Rust game server to the Runic Gateway website. + +Usage: rust-link-sidecar [OPTIONS] + +Options: + --print-config Resolve the configuration, print it as JSON, and exit. + Runs first-run setup like a normal start does: if the + config file is missing it is written, and a blank auth + token is generated and saved. The JSON CONTAINS THE + AUTH TOKEN in clear text. + --config Path to sidecar.toml. Overrides $RUSTLINK_CONFIG; + defaults to ./sidecar.toml. + -V, --version Print the sidecar and protocol versions and exit. + -h, --help Print this help and exit. + +Configuration lives in sidecar.toml; environment variables override the file: + RUSTLINK_CONFIG, RUSTLINK_GAME_BIND, RUSTLINK_WEB_BIND, RUSTLINK_WEB_TOKEN, + RUSTLINK_DB_PATH, RUSTLINK_SERVER_ID +"; + +/// Parses arguments **without** the program name. +/// +/// Returns the message to print on stderr when the arguments are unusable; the caller exits `2`. +pub fn parse>(args: I) -> Result { + let mut mode = Mode::Run; + let mut config = None; + let mut it = args.into_iter(); + + while let Some(arg) = it.next() { + match arg.as_str() { + "--print-config" => mode = Mode::PrintConfig, + "-h" | "--help" => { + return Ok(Cli { + mode: Mode::Help, + config, + }) + } + "-V" | "--version" => { + return Ok(Cli { + mode: Mode::Version, + config, + }) + } + "--config" => { + // `--config` with nothing after it would otherwise silently fall through and start + // the sidecar against the default config — the opposite of what was asked for. + let path = it + .next() + .ok_or_else(|| "--config requires a path".to_string())?; + config = Some(path); + } + _ => match arg.strip_prefix("--config=") { + Some("") => return Err("--config requires a path".into()), + Some(path) => config = Some(path.to_string()), + None => return Err(format!("unrecognized argument: {arg}")), + }, + } + } + + Ok(Cli { mode, config }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_str(args: &[&str]) -> Result { + parse(args.iter().map(|s| s.to_string())) + } + + #[test] + fn no_arguments_runs_the_sidecar() { + let cli = parse_str(&[]).unwrap(); + assert_eq!(cli.mode, Mode::Run); + assert_eq!(cli.config, None); + } + + #[test] + fn print_config_is_recognized() { + assert_eq!( + parse_str(&["--print-config"]).unwrap().mode, + Mode::PrintConfig + ); + } + + #[test] + fn config_accepts_both_spellings() { + let spaced = parse_str(&["--config", "/etc/runicgateway/sidecar.toml"]).unwrap(); + let equals = parse_str(&["--config=/etc/runicgateway/sidecar.toml"]).unwrap(); + assert_eq!( + spaced.config.as_deref(), + Some("/etc/runicgateway/sidecar.toml") + ); + assert_eq!(spaced, equals); + } + + #[test] + fn config_combines_with_print_config() { + let cli = parse_str(&["--config", "c.toml", "--print-config"]).unwrap(); + assert_eq!(cli.mode, Mode::PrintConfig); + assert_eq!(cli.config.as_deref(), Some("c.toml")); + } + + #[test] + fn a_path_is_never_swallowed_as_a_flag() { + // `--config --print-config` takes the next token as the path, wrong as that path is. The + // alternative — treating it as a missing value — guesses at intent. + let cli = parse_str(&["--config", "--print-config"]).unwrap(); + assert_eq!(cli.mode, Mode::Run); + assert_eq!(cli.config.as_deref(), Some("--print-config")); + } + + #[test] + fn config_without_a_value_is_an_error() { + assert!(parse_str(&["--config"]).is_err()); + assert!(parse_str(&["--config="]).is_err()); + } + + #[test] + fn unknown_arguments_are_rejected() { + // Silently ignoring a typo'd flag would start a sidecar that is not what was asked for. + let err = parse_str(&["--pirnt-config"]).unwrap_err(); + assert!(err.contains("--pirnt-config"), "{err}"); + assert!(parse_str(&["/etc/runicgateway/sidecar.toml"]).is_err()); + } + + #[test] + fn help_and_version_win_immediately() { + assert_eq!(parse_str(&["--help", "--bogus"]).unwrap().mode, Mode::Help); + assert_eq!(parse_str(&["-h"]).unwrap().mode, Mode::Help); + assert_eq!( + parse_str(&["--version", "--bogus"]).unwrap().mode, + Mode::Version + ); + assert_eq!(parse_str(&["-V"]).unwrap().mode, Mode::Version); + } +} diff --git a/sidecar/src/config.rs b/sidecar/src/config.rs new file mode 100644 index 0000000..9d91374 --- /dev/null +++ b/sidecar/src/config.rs @@ -0,0 +1,423 @@ +//! Runtime configuration, loaded from an external file — nothing here is compiled into the binary. +//! +//! Precedence: environment variables override the file, the file overrides built-in defaults. On +//! first run, if the file is absent, a default one is written with a freshly generated auth token, +//! so the sidecar is secured out of the box and the operator just copies the token to the website. +//! +//! File path: `--config `, else `$RUSTLINK_CONFIG`, else `sidecar.toml` in the working +//! directory. +//! +//! **Paths are anchored to the config file, not the working directory.** A relative `[store].path` +//! resolves against the directory holding `sidecar.toml`, so a service started with +//! `RUSTLINK_CONFIG=/etc/runicgateway/rust-main.toml` keeps its database beside its config instead +//! of wherever the service manager happened to set the working directory. +//! +//! # `server_id`, and why it is here rather than only in the plugin +//! +//! R8 makes the platform multi-server: one sidecar per game server, and every row the module +//! stores carries the server it came from. The plugin declares its own `serverId` in `server.hello` +//! and that is the authority. This setting is a **cross-check**, not a second source of truth: when +//! both are set and they disagree, the sidecar logs the disagreement loudly and keeps the plugin's. +//! Two servers pointed at one sidecar by a copy-pasted config is the mistake this catches, and it +//! is silent in every other design. + +use std::env; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +use serde::Deserialize; +use serde_json::json; +use tracing::info; + +use crate::PROTOCOL_VERSION; + +#[derive(Debug, Default, Deserialize)] +pub struct Config { + #[serde(default)] + pub game: GameCfg, + #[serde(default)] + pub web: WebCfg, + #[serde(default)] + pub store: StoreCfg, +} + +#[derive(Debug, Deserialize)] +pub struct GameCfg { + #[serde(default = "default_game_bind")] + pub bind: String, + /// Optional cross-check against the `serverId` the plugin announces. See the module docs. + #[serde(default)] + pub server_id: String, +} + +#[derive(Debug, Deserialize)] +pub struct WebCfg { + #[serde(default = "default_web_bind")] + pub bind: String, + /// Shared secret the website must present. Never empty in practice — `Config::load` generates + /// and persists one when it finds none, so the web surface is authenticated from first boot. + #[serde(default)] + pub auth_token: String, +} + +#[derive(Debug, Deserialize)] +pub struct StoreCfg { + #[serde(default = "default_db_path")] + pub path: String, +} + +/// A loaded configuration plus what loading it *did* — an installer re-running the binary needs to +/// distinguish "read an existing install" from "provisioned a new one", and it cannot tell from the +/// values alone. +#[derive(Debug)] +pub struct Loaded { + pub cfg: Config, + /// Absolute path of the config file that was read or written. + pub path: PathBuf, + /// The config file did not exist and was created by this run. + pub config_created: bool, + /// No usable token was configured, so one was generated and saved. + pub token_generated: bool, +} + +fn default_game_bind() -> String { + "127.0.0.1:7799".into() +} +fn default_web_bind() -> String { + "127.0.0.1:8090".into() +} +fn default_db_path() -> String { + "rust-link.db".into() +} + +impl Default for GameCfg { + fn default() -> Self { + Self { + bind: default_game_bind(), + server_id: String::new(), + } + } +} +impl Default for WebCfg { + fn default() -> Self { + Self { + bind: default_web_bind(), + auth_token: String::new(), + } + } +} +impl Default for StoreCfg { + fn default() -> Self { + Self { + path: default_db_path(), + } + } +} + +impl Config { + /// Which config file this invocation will use: `--config`, else `$RUSTLINK_CONFIG`, else + /// `sidecar.toml` beside the working directory. Always returned absolute, so every later + /// message names a path the operator can act on. + pub fn resolve_path(cli_override: Option<&str>) -> PathBuf { + let raw = cli_override + .map(str::to_string) + .or_else(|| env::var("RUSTLINK_CONFIG").ok()) + .unwrap_or_else(|| "sidecar.toml".into()); + absolutize(PathBuf::from(raw)) + } + + pub fn load(cli_override: Option<&str>) -> anyhow::Result { + let path = Self::resolve_path(cli_override); + let existed = path.exists(); + + let mut cfg: Config = if existed { + let text = fs::read_to_string(&path)?; + toml::from_str(&text)? + } else { + Config::default() + }; + + cfg.apply_env(); + + // Authentication is always on. A blank token is never allowed — if none is set (fresh + // install, or someone cleared it), generate one, save it, and continue. This keeps setup + // effortless while making it impossible to accidentally run with auth off. + let token_generated = cfg.web.auth_token.trim().is_empty(); + if token_generated { + let token = generate_token(); + + if existed { + persist_token(&path, &token)?; + } else { + // The parent may not exist yet when an installer points at a fresh + // /etc/runicgateway; failing here would mean "run me again after mkdir". + create_parent_dir(&path)?; + fs::write(&path, default_file(&token))?; + } + + cfg.web.auth_token = token.clone(); + + info!("No auth token configured."); + info!("Generated new token: {}", token); + info!("Saved to {}. Authentication is on.", path.display()); + } + + cfg.anchor_store_path(&path); + + Ok(Loaded { + cfg, + path, + config_created: !existed, + token_generated, + }) + } + + /// Environment overrides, so a deployment can set secrets without editing the file. + fn apply_env(&mut self) { + if let Ok(v) = env::var("RUSTLINK_GAME_BIND") { + self.game.bind = v; + } + if let Ok(v) = env::var("RUSTLINK_SERVER_ID") { + self.game.server_id = v; + } + if let Ok(v) = env::var("RUSTLINK_WEB_BIND") { + self.web.bind = v; + } + if let Ok(v) = env::var("RUSTLINK_WEB_TOKEN") { + self.web.auth_token = v; + } + if let Ok(v) = env::var("RUSTLINK_DB_PATH") { + self.store.path = v; + } + } + + /// Resolves `[store].path` against the config file's directory (see the module docs). Absolute + /// paths and SQLite's non-filesystem spellings are left exactly as written. + fn anchor_store_path(&mut self, config_path: &Path) { + if is_sqlite_special(&self.store.path) { + return; + } + let raw = PathBuf::from(&self.store.path); + let anchored = if raw.is_absolute() { + raw + } else { + config_dir(config_path).join(raw) + }; + self.store.path = absolutize(anchored).to_string_lossy().into_owned(); + } + + pub fn auth_required(&self) -> bool { + // Always true now — load() guarantees a non-empty token. + !self.web.auth_token.is_empty() + } +} + +/// The `--print-config` document: everything an installer needs to register this sidecar with a +/// website, in one non-interactive read. +/// +/// **This includes the auth token in clear text**, which is the point: the manual token hunt is the +/// largest "I installed it and nothing happened" failure mode. The caller prints it to stdout and +/// starts no log subscriber, so the document is the whole output. +pub fn describe(loaded: &Loaded) -> serde_json::Value { + json!({ + "component": "rust-link-sidecar", + "version": env!("CARGO_PKG_VERSION"), + "protocol": PROTOCOL_VERSION, + "config_path": loaded.path.to_string_lossy(), + "config_created": loaded.config_created, + "token_generated": loaded.token_generated, + "game": { + "bind": loaded.cfg.game.bind, + "server_id": loaded.cfg.game.server_id, + }, + "web": { + "bind": loaded.cfg.web.bind, + "ws_path": crate::web::WS_PATH, + "auth_required": loaded.cfg.auth_required(), + "auth_token": loaded.cfg.web.auth_token, + }, + "store": { "path": loaded.cfg.store.path }, + }) +} + +/// Directory holding the config file. A bare `sidecar.toml` has no parent component, which would +/// join into an empty base — treat it as the current directory. +fn config_dir(config_path: &Path) -> PathBuf { + match config_path.parent() { + Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(), + _ => PathBuf::from("."), + } +} + +/// Prefixes the working directory onto a relative path, then drops the `.` components that +/// joining leaves behind — cosmetic, but these paths are printed and pasted into service units. +fn absolutize(p: PathBuf) -> PathBuf { + let joined = if p.is_absolute() { + p + } else { + match env::current_dir() { + Ok(cwd) => cwd.join(p), + Err(_) => p, + } + }; + let cleaned: PathBuf = joined + .components() + .filter(|c| !matches!(c, Component::CurDir)) + .collect(); + if cleaned.as_os_str().is_empty() { + joined + } else { + cleaned + } +} + +/// `:memory:` and `file:` URIs are instructions to SQLite, not paths on disk. Anchoring them to a +/// directory would turn a working in-memory store into an attempt to create a file called +/// `:memory:` — which Windows cannot even name. +fn is_sqlite_special(path: &str) -> bool { + path == ":memory:" || path.starts_with("file:") +} + +fn create_parent_dir(path: &Path) -> anyhow::Result<()> { + if let Some(dir) = path.parent() { + if !dir.as_os_str().is_empty() && !dir.exists() { + fs::create_dir_all(dir)?; + } + } + Ok(()) +} + +/// Rewrites the `auth_token` line in an existing config file, preserving everything else. Falls +/// back to inserting it under `[web]`, or appending a `[web]` section, if the key is absent. +fn persist_token(path: &Path, token: &str) -> anyhow::Result<()> { + let text = fs::read_to_string(path)?; + let line = format!("auth_token = \"{token}\""); + + if text + .lines() + .any(|l| l.trim_start().starts_with("auth_token")) + { + let out: String = text + .lines() + .map(|l| { + if l.trim_start().starts_with("auth_token") { + line.clone() + } else { + l.to_string() + } + }) + .collect::>() + .join("\n"); + fs::write(path, out + "\n")?; + } else if text.lines().any(|l| l.trim() == "[web]") { + let out: String = text + .lines() + .flat_map(|l| { + if l.trim() == "[web]" { + vec![l.to_string(), line.clone()] + } else { + vec![l.to_string()] + } + }) + .collect::>() + .join("\n"); + fs::write(path, out + "\n")?; + } else { + fs::write(path, format!("{text}\n[web]\n{line}\n"))?; + } + + Ok(()) +} + +fn generate_token() -> String { + let mut buf = [0u8; 24]; + // OS randomness; falls back to a time-seeded token only if the OS RNG is unavailable. + if getrandom::getrandom(&mut buf).is_err() { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + return format!("insecure-fallback-{nanos:x}"); + } + buf.iter().map(|b| format!("{b:02x}")).collect() +} + +fn default_file(token: &str) -> String { + format!( + r#"# rust-link sidecar configuration. +# +# One sidecar serves one Rust game server. A community running six servers runs +# six of these, each with its own port, its own database and its own token. +# +# Environment variables override every value here. + +[game] +# Where the Oxide bridge plugin dials in. The plugin is the client; this is the +# listener, which is why the game server itself opens no extra port. +bind = "127.0.0.1:7799" + +# Optional. If set, it is cross-checked against the serverId the plugin +# announces in server.hello; a disagreement is logged and the plugin wins. +server_id = "" + +[web] +# Where the website reaches this sidecar. Bind to a LAN or public address only +# behind TLS and a firewall — the token below is the only thing guarding it. +bind = "127.0.0.1:8090" + +# Generated on first run. Paste it into the website's Rust server form. It is +# write-only there: the site never shows it back. +auth_token = "{token}" + +[store] +# Relative paths resolve against the directory holding THIS FILE, not the +# working directory of whatever started the process. +path = "rust-link.db" +"# + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_generated_token_is_48_hex_characters() { + let t = generate_token(); + assert!(!t.starts_with("insecure-fallback-"), "OS RNG unavailable"); + assert_eq!(t.len(), 48); + assert!(t.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn sqlite_special_paths_are_not_anchored() { + let mut cfg = Config::default(); + cfg.store.path = ":memory:".into(); + cfg.anchor_store_path(Path::new("/etc/runicgateway/sidecar.toml")); + assert_eq!(cfg.store.path, ":memory:"); + } + + /// The whole point of anchoring: a service manager's working directory must not decide where + /// the database lands. + #[test] + fn a_relative_store_path_anchors_to_the_config_directory() { + let mut cfg = Config::default(); + cfg.store.path = "rust-link.db".into(); + let config_path = absolutize(PathBuf::from("cfgdir/sidecar.toml")); + cfg.anchor_store_path(&config_path); + + let expected = config_path.parent().unwrap().join("rust-link.db"); + assert_eq!(PathBuf::from(&cfg.store.path), expected); + } + + /// The written default must be loadable by the loader that wrote it — a template with a typo + /// in it fails on the second start, not the first. + #[test] + fn the_default_file_round_trips() { + let cfg: Config = toml::from_str(&default_file("deadbeef")).unwrap(); + assert_eq!(cfg.web.auth_token, "deadbeef"); + assert_eq!(cfg.game.bind, default_game_bind()); + assert_eq!(cfg.store.path, default_db_path()); + assert_eq!(cfg.game.server_id, ""); + } +} diff --git a/sidecar/src/game.rs b/sidecar/src/game.rs new file mode 100644 index 0000000..3553aca --- /dev/null +++ b/sidecar/src/game.rs @@ -0,0 +1,415 @@ +//! The loopback link to the Rust server's Oxide bridge plugin. +//! +//! The plugin is the TCP *client*: it dials out to us. So the sidecar owns the listener, and the +//! plugin's outbound socket is the only thing that ever connects. This is the whole reason the game +//! is never directly reachable from the website — it exposes no port for us. +//! +//! Framing is newline-delimited JSON, bidirectional: the plugin sends events (`kind`), we send +//! commands (`cmd`). We accept one plugin connection at a time and re-accept when it drops (the +//! plugin reconnects on its own, with a bounded backoff). +//! +//! **Loopback is the trust boundary.** There is no token on this link, exactly as on the ServUO +//! bridge: the plugin and the sidecar share a host, and the address to bind is `127.0.0.1`. Binding +//! `[game].bind` to anything routable puts an unauthenticated command channel on the network, and +//! the operator documentation says so in as many words. +//! +//! Inbound lines are **capped** (see [`MAX_INBOUND_LINE_BYTES`]) from the start rather than after +//! the first large frame arrives: an unbounded `read_line` facing a peer that will one day send a +//! map image is a memory-exhaustion shape we would be inventing ourselves. + +use std::sync::Arc; + +use serde_json::Value; +use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::TcpListener; +use tokio::sync::{mpsc, Mutex}; +use tracing::{info, warn}; + +/// The longest line the sidecar will accept from the plugin, in bytes. +/// +/// Over-long lines are **discarded, not buffered**, and the connection stays up: a single malformed +/// frame is not a reason to tear down a link that live events are flowing over. A dropped reply +/// simply times out on the caller's side and is re-requested. +pub const MAX_INBOUND_LINE_BYTES: usize = 1024 * 1024; + +/// What one read off the plugin socket produced. +#[derive(Debug)] +enum Line { + /// A complete line, within the cap. + Complete(String), + /// A line that ran past the cap. Carries how many bytes were thrown away, for the log. + TooLong(usize), + /// The plugin closed the connection. + Eof, +} + +/// A cancel-safe, capped, newline-delimited reader. +/// +/// Every piece of state that must survive a partial read lives here rather than in a local, because +/// this is polled inside a `tokio::select!`: the loop below drops the future whenever a command +/// wins the race, and a `discarding` flag or a half-filled buffer held in a local would be lost +/// with it. Losing the buffer corrupts the *next* line; losing `discarding` turns the tail of an +/// over-long line into a line of its own. Both are silent. +/// +/// The only await point is `fill_buf`, and nothing is consumed until after it returns, so a +/// cancellation between the two can lose at most the wakeup. +#[derive(Default)] +struct LineReader { + buf: Vec, + discarding: bool, + discarded: usize, +} + +impl LineReader { + async fn next(&mut self, reader: &mut R) -> std::io::Result { + loop { + let consumed; + let outcome; + + { + let available = reader.fill_buf().await?; + + if available.is_empty() { + return Ok(Line::Eof); + } + + match available.iter().position(|&b| b == b'\n') { + Some(at) => { + consumed = at + 1; + + if self.discarding { + // The tail of a line we already gave up on. Swallow it, terminator + // included, and report the size once. + self.discarded += at; + let total = self.discarded; + self.discarding = false; + self.discarded = 0; + outcome = Some(Line::TooLong(total)); + } else if self.buf.len() + at > MAX_INBOUND_LINE_BYTES { + // The cap is reached only now, on the chunk that also holds the + // terminator — so there is nothing left to discard. + let total = self.buf.len() + at; + self.buf.clear(); + outcome = Some(Line::TooLong(total)); + } else { + self.buf.extend_from_slice(&available[..at]); + let line = String::from_utf8_lossy(&self.buf).into_owned(); + self.buf.clear(); + outcome = Some(Line::Complete(line)); + } + } + None => { + consumed = available.len(); + + if self.discarding { + self.discarded += consumed; + } else if self.buf.len() + consumed > MAX_INBOUND_LINE_BYTES { + // Refuse rather than buffer: this is the whole point of the cap. + // Everything up to the next newline is now dropped on the floor. + self.discarded = self.buf.len() + consumed; + self.buf.clear(); + self.discarding = true; + } else { + self.buf.extend_from_slice(available); + } + + outcome = None; + } + } + } + + reader.consume(consumed); + + if let Some(line) = outcome { + return Ok(line); + } + } + } +} + +/// An event line received from the plugin, parsed. `kind` is lifted out for routing. +#[derive(Debug, Clone)] +pub struct GameEvent { + pub kind: String, + pub value: Value, +} + +/// A handle for sending command lines to the plugin. Cloneable and cheap. +/// +/// Commands are dropped (with a warning) when no plugin is connected, rather than buffered: a +/// website query that arrives during an outage should fail fast and be retried, not silently queue +/// behind a reconnect. Live *events* are what must survive an outage, and those the plugin buffers +/// on its side. +#[derive(Clone)] +pub struct GameHandle { + tx: Arc>>>, +} + +impl GameHandle { + fn new() -> Self { + Self { + tx: Arc::new(Mutex::new(None)), + } + } + + async fn set(&self, sender: Option>) { + *self.tx.lock().await = sender; + } + + /// Send one command line (a complete JSON object, no newline — we add the frame delimiter). + /// Returns false if no plugin is currently connected. + pub async fn send(&self, line: String) -> bool { + let guard = self.tx.lock().await; + match guard.as_ref() { + Some(sender) => sender.send(line).is_ok(), + None => { + warn!("dropping command; no plugin connected"); + false + } + } + } + + pub async fn is_connected(&self) -> bool { + self.tx.lock().await.is_some() + } +} + +/// Binds the loopback listener and accepts plugin connections forever. Each accepted connection +/// runs until it drops, then we loop back to accept the next one. Events are forwarded to +/// `event_tx`; the returned handle sends commands to whichever plugin is currently connected. +/// +/// The **bound** address is returned alongside the handle rather than assumed to be the one asked +/// for: `127.0.0.1:0` is a legitimate thing to configure (and what the tests use), and a log line +/// echoing the request rather than the result is the kind that is wrong exactly when it matters. +pub async fn serve( + addr: &str, + event_tx: mpsc::UnboundedSender, +) -> std::io::Result<(GameHandle, std::net::SocketAddr)> { + let listener = TcpListener::bind(addr).await?; + let bound = listener.local_addr()?; + info!(addr = %bound, "game link listening"); + + let handle = GameHandle::new(); + let accept_handle = handle.clone(); + + tokio::spawn(async move { + loop { + match listener.accept().await { + Ok((stream, peer)) => { + info!(%peer, "plugin connected"); + if let Err(e) = handle_connection(stream, &event_tx, &accept_handle).await { + warn!(error = %e, "plugin connection ended"); + } else { + info!("plugin disconnected"); + } + accept_handle.set(None).await; + // A disconnect is a fact the website should see without polling, so it rides + // the same channel every other fact does. Nothing persists it. + let _ = event_tx.send(GameEvent { + kind: "link.down".to_string(), + value: serde_json::json!({ "kind": "link.down" }), + }); + } + Err(e) => { + warn!(error = %e, "accept failed"); + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + } + } + } + }); + + Ok((handle, bound)) +} + +async fn handle_connection( + stream: tokio::net::TcpStream, + event_tx: &mpsc::UnboundedSender, + handle: &GameHandle, +) -> std::io::Result<()> { + stream.set_nodelay(true).ok(); + let (read_half, mut write_half) = stream.into_split(); + + // Install the outbound command channel for this connection. + let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::(); + handle.set(Some(cmd_tx)).await; + + let mut reader = BufReader::new(read_half); + let mut lines = LineReader::default(); + + loop { + tokio::select! { + // Inbound: a line from the plugin. + result = lines.next(&mut reader) => { + match result? { + Line::Eof => return Ok(()), // clean EOF: plugin closed + Line::TooLong(bytes) => { + // Deliberately not a disconnect. See MAX_INBOUND_LINE_BYTES. + warn!( + bytes, + cap = MAX_INBOUND_LINE_BYTES, + "inbound line over the cap; discarded" + ); + } + Line::Complete(line) => { + let trimmed = line.trim_end(); + if !trimmed.is_empty() { + match serde_json::from_str::(trimmed) { + Ok(value) => { + let kind = value + .get("kind") + .and_then(|k| k.as_str()) + .unwrap_or("") + .to_string(); + let _ = event_tx.send(GameEvent { kind, value }); + } + Err(e) => warn!(error = %e, line = %trimmed, "unparseable event"), + } + } + } + } + } + // Outbound: a command to write to the plugin. + cmd = cmd_rx.recv() => { + match cmd { + Some(mut c) => { + c.push('\n'); + write_half.write_all(c.as_bytes()).await?; + write_half.flush().await?; + } + None => return Ok(()), // channel closed + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Drives `LineReader` over a byte slice, returning every outcome up to EOF. + async fn read_all(input: &[u8]) -> Vec { + let mut reader = BufReader::with_capacity(64, input); + let mut lines = LineReader::default(); + let mut out = Vec::new(); + + loop { + match lines.next(&mut reader).await.unwrap() { + Line::Eof => break, + other => out.push(other), + } + } + + out + } + + fn complete(lines: &[Line]) -> Vec<&str> { + lines + .iter() + .filter_map(|l| match l { + Line::Complete(s) => Some(s.as_str()), + _ => None, + }) + .collect() + } + + #[tokio::test] + async fn splits_on_newlines() { + let lines = read_all(b"{\"a\":1}\n{\"b\":2}\n").await; + assert_eq!(complete(&lines), vec!["{\"a\":1}", "{\"b\":2}"]); + } + + /// The reader's buffer is 64 bytes here, so every one of these lines spans several `fill_buf` + /// chunks. Reassembly across chunks is the thing `read_line` would have done for us. + #[tokio::test] + async fn reassembles_across_chunks() { + let long = "x".repeat(500); + let input = format!("{}\n{}\n", long, long); + let lines = read_all(input.as_bytes()).await; + + assert_eq!(complete(&lines), vec![long.as_str(), long.as_str()]); + } + + /// The cap itself. The over-long line must be reported and thrown away, and — the part that + /// actually matters — the line *after* it must still arrive intact. + #[tokio::test] + async fn refuses_an_over_long_line_and_recovers() { + let mut input = Vec::new(); + input.extend_from_slice(&b"a".repeat(MAX_INBOUND_LINE_BYTES + 10)); + input.push(b'\n'); + input.extend_from_slice(b"{\"kind\":\"pong\"}\n"); + + let lines = read_all(&input).await; + + assert_eq!(lines.len(), 2); + assert!( + matches!(lines[0], Line::TooLong(n) if n >= MAX_INBOUND_LINE_BYTES), + "expected TooLong, got {:?}", + lines[0] + ); + assert_eq!(complete(&lines), vec!["{\"kind\":\"pong\"}"]); + } + + /// A line of exactly the cap is legal; one byte more is not. Checking both sides is what says + /// the comparison is `>` rather than `>=`, which would silently cost a byte of the budget. + #[tokio::test] + async fn the_cap_is_inclusive() { + let at_cap = "b".repeat(MAX_INBOUND_LINE_BYTES); + let lines = read_all(format!("{}\n", at_cap).as_bytes()).await; + assert_eq!(complete(&lines).len(), 1); + + let over = "b".repeat(MAX_INBOUND_LINE_BYTES + 1); + let lines = read_all(format!("{}\n", over).as_bytes()).await; + assert!(complete(&lines).is_empty()); + assert!(matches!(lines[0], Line::TooLong(_))); + } + + /// An over-long line whose terminator lands in the very chunk that crosses the cap: the reader + /// must not leave itself in `discarding` and eat the next line as well. + #[tokio::test] + async fn over_long_line_terminating_in_the_crossing_chunk() { + let mut input = Vec::new(); + input.extend_from_slice(&b"c".repeat(MAX_INBOUND_LINE_BYTES + 1)); + input.extend_from_slice(b"\n{\"kind\":\"pong\"}\n"); + + let lines = read_all(&input).await; + + assert!(matches!(lines[0], Line::TooLong(_))); + assert_eq!(complete(&lines), vec!["{\"kind\":\"pong\"}"]); + } + + /// A partial line at EOF is dropped rather than delivered half-parsed. The plugin reconnects + /// and re-sends; half a JSON object is not something to hand to the event fan-out. + #[tokio::test] + async fn trailing_partial_line_at_eof_is_dropped() { + let lines = read_all(b"{\"a\":1}\n{\"b\":").await; + assert_eq!(complete(&lines), vec!["{\"a\":1}"]); + } + + /// The end-to-end shape of the link, over a real socket: the plugin dials in, sends a hello, + /// and receives a command. This is the criterion phase 1 is judged on, minus the two peers. + #[tokio::test] + async fn a_dialling_plugin_is_accepted_and_can_be_commanded() { + use tokio::io::AsyncReadExt; + + let (tx, mut rx) = mpsc::unbounded_channel(); + let (handle, addr) = serve("127.0.0.1:0", tx).await.unwrap(); + + let mut client = tokio::net::TcpStream::connect(addr).await.unwrap(); + client + .write_all(b"{\"kind\":\"server.hello\",\"serverId\":\"main\"}\n") + .await + .unwrap(); + + let ev = rx.recv().await.unwrap(); + assert_eq!(ev.kind, "server.hello"); + assert_eq!(ev.value["serverId"], "main"); + + assert!(handle.is_connected().await); + assert!(handle.send("{\"cmd\":\"ping\"}".to_string()).await); + + let mut buf = [0u8; 64]; + let n = client.read(&mut buf).await.unwrap(); + assert_eq!(&buf[..n], b"{\"cmd\":\"ping\"}\n"); + } +} diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs new file mode 100644 index 0000000..0e411bb --- /dev/null +++ b/sidecar/src/main.rs @@ -0,0 +1,134 @@ +//! rust-link sidecar. +//! +//! Terminates the loopback link to a Rust game server's Oxide bridge plugin and exposes a +//! website-facing HTTP surface: a WebSocket live feed and REST queries backed by SQLite. +//! +//! # Why the game does not listen +//! +//! The plugin is the TCP *client*; this process owns the listener. A Rust server therefore opens +//! no extra port, and the only component the website can reach is this one. That invariant is +//! inherited wholesale from the ServUO bridge — the footing changed (Oxide hooks instead of shard +//! source) and the shape did not. +//! +//! # One server, one sidecar +//! +//! This binary serves exactly one game server. A community running six servers runs six pairs, and +//! `module-rust` holds six clients; core never learns there is more than one. Nothing here is +//! multiplexed, and nothing here should become multiplexed — the `serverId` on every frame exists +//! so the *module* can tell its own clients apart, not so this process can. +//! +//! # Layout +//! +//! `main` does argument handling and nothing else; the sidecar proper lives in [`app`], which is +//! parameterised on `ready`/`shutdown` so that a future service wrapper (the installer's phase) +//! can supply the host's own start and stop without restructuring anything. + +mod app; +mod cli; +mod config; +mod game; +mod rpc; +mod store; +mod web; + +use tracing_subscriber::EnvFilter; + +/// Wire-protocol version between the website and this sidecar, and between this sidecar and the +/// bridge plugin. Bump it whenever a frame's shape changes, so a mismatched peer is detected +/// immediately (`409` on HTTP, a refusal in the log on the game link) rather than mis-parsed. +/// +/// It is declared in **three** places and they must agree: here, in `Module-Rust`'s +/// `module.json`, and in `Rust-Plugins`' `overlay.toml`. The installer refuses to pair a sidecar +/// and an overlay that disagree, so a bump lands in the same change as the emitters it describes. +/// +/// # Protocol 1 — the transport +/// +/// Everything phase 1 defines, and deliberately nothing more: +/// +/// * **`server.hello`** — the one event. The plugin sends it on every successful connect, not +/// once at server start: this process restarts independently of the game, so anything the +/// sidecar needs up front has to be re-sent per connection. It carries the `bootId`, which is +/// how a game restart is told apart from a sidecar reconnect — the distinction the event +/// system's `reconcile` will later hang off. +/// * **`ping` / `pong`** — the heartbeat. The sidecar asks, the plugin answers. A `pong` is +/// ephemeral chatter and is never persisted; it only moves `last_event`. +/// * **`server.status`** — the one request/reply verb, correlated by `reqId`. It exists in +/// phase 1 so the correlation path is exercised by something before anything depends on it. +/// +/// Both directions are newline-delimited JSON over TCP. Outbound frames (plugin -> sidecar) carry +/// `kind`; inbound frames (sidecar -> plugin) carry `cmd`. +pub const PROTOCOL_VERSION: u32 = 1; + +fn main() -> anyhow::Result<()> { + let args = match cli::parse(std::env::args().skip(1)) { + Ok(args) => args, + Err(msg) => { + eprintln!("rust-link-sidecar: {msg}\n\n{}", cli::USAGE); + std::process::exit(2); + } + }; + + match args.mode { + cli::Mode::Help => { + print!("{}", cli::USAGE); + return Ok(()); + } + cli::Mode::Version => { + println!( + "rust-link-sidecar {} (protocol {})", + env!("CARGO_PKG_VERSION"), + PROTOCOL_VERSION + ); + return Ok(()); + } + // Tracing stays uninitialized here on purpose: the subscriber writes to stdout, and on this + // path stdout is the document. An installer parses it. + cli::Mode::PrintConfig => { + let loaded = config::Config::load(args.config.as_deref())?; + println!("{:#}", config::describe(&loaded)); + return Ok(()); + } + cli::Mode::Run => {} + } + + init_console_tracing(); + + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + + runtime.block_on(app::run(args.config.as_deref(), || {}, shutdown_signal())) +} + +/// Logging for a foreground run: human-readable, on stdout. +pub fn init_console_tracing() { + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), + ) + .init(); +} + +/// Resolves on Ctrl-C, and on `SIGTERM` where there is one. +async fn shutdown_signal() { + #[cfg(unix)] + { + use tokio::signal::unix::{signal, SignalKind}; + let mut term = match signal(SignalKind::terminate()) { + Ok(s) => s, + Err(_) => { + let _ = tokio::signal::ctrl_c().await; + return; + } + }; + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + _ = term.recv() => {} + } + } + + #[cfg(not(unix))] + { + let _ = tokio::signal::ctrl_c().await; + } +} diff --git a/sidecar/src/rpc.rs b/sidecar/src/rpc.rs new file mode 100644 index 0000000..29478bd --- /dev/null +++ b/sidecar/src/rpc.rs @@ -0,0 +1,135 @@ +//! Request/reply correlation over the one game socket. +//! +//! REST is synchronous ("what is the server doing right now"); the game link is an async stream of +//! lines. This bridges them: a call registers a pending entry under a correlation id, sends the +//! command, and awaits a reply carrying that id. The event loop routes any incoming line whose +//! correlation id is pending back to the waiting caller; everything else flows on as a normal +//! event. +//! +//! One correlation field is recognised in protocol 1 — **`reqId`** — and it is a process-unique +//! counter. The UO bridge grew two more over eight protocol versions because callers there supply +//! their own ids for some verbs; that is a reason to keep the routing table keyed by string, not a +//! reason to invent the extra keys now. +//! +//! **The timeout here is a ceiling every later command budget sits under.** Core classifies a +//! budget overrun as retryable unconditionally — it cannot ask the game, because the action is +//! still awaiting a socket — so an action whose `budgetMs` exceeds [`REPLY_TIMEOUT`] can never +//! report `retry: false`. Derive one from the other rather than writing both down. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use serde_json::Value; +use tokio::sync::{oneshot, Mutex}; + +use crate::game::GameHandle; + +/// How long a correlated call waits for its reply before giving up. +pub const REPLY_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Clone)] +pub struct Rpc { + pending: Arc>>>, + counter: Arc, +} + +#[derive(Debug)] +pub enum RpcError { + NoPlugin, + Timeout, +} + +impl Rpc { + pub fn new() -> Self { + Self { + pending: Arc::new(Mutex::new(HashMap::new())), + counter: Arc::new(AtomicU64::new(1)), + } + } + + pub fn next_req_id(&self) -> String { + format!("r-{}", self.counter.fetch_add(1, Ordering::Relaxed)) + } + + /// Sends `command` to the plugin and awaits the reply correlated by `corr_val`. The command + /// must already contain the correlation field (`reqId`) set to `corr_val`. + pub async fn call( + &self, + game: &GameHandle, + command: Value, + corr_val: &str, + ) -> Result { + let (tx, rx) = oneshot::channel(); + + self.pending.lock().await.insert(corr_val.to_string(), tx); + + if !game.send(command.to_string()).await { + self.pending.lock().await.remove(corr_val); + return Err(RpcError::NoPlugin); + } + + match tokio::time::timeout(REPLY_TIMEOUT, rx).await { + Ok(Ok(value)) => Ok(value), + _ => { + // Both the timeout and a dropped sender land here, and both must clear the entry: + // a pending map that only ever grows is a leak keyed by a counter. + self.pending.lock().await.remove(corr_val); + Err(RpcError::Timeout) + } + } + } + + /// If this incoming value correlates to a pending call, complete it and return true (the value + /// was a reply, not a broadcast event). Otherwise return false. + pub async fn try_route(&self, value: &Value) -> bool { + let corr = match value.get("reqId").and_then(|v| v.as_str()) { + Some(c) => c.to_string(), + None => return false, + }; + + let sender = self.pending.lock().await.remove(&corr); + match sender { + Some(tx) => { + let _ = tx.send(value.clone()); + true + } + None => false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[tokio::test] + async fn an_unknown_req_id_is_not_a_reply() { + let rpc = Rpc::new(); + assert!(!rpc.try_route(&json!({"kind": "server.hello"})).await); + assert!(!rpc.try_route(&json!({"reqId": "r-999"})).await); + } + + #[tokio::test] + async fn req_ids_are_unique_within_a_process() { + let rpc = Rpc::new(); + let a = rpc.next_req_id(); + let b = rpc.next_req_id(); + assert_ne!(a, b); + } + + /// A reply routed while nothing is waiting must flow on as an ordinary event rather than be + /// swallowed — that is the difference between a late reply and a lost one. + #[tokio::test] + async fn a_late_reply_falls_through_to_the_event_path() { + let rpc = Rpc::new(); + let (tx, _rx) = oneshot::channel(); + rpc.pending.lock().await.insert("r-1".into(), tx); + + assert!(rpc.try_route(&json!({"reqId": "r-1"})).await); + // Second delivery of the same id: the entry is gone, so it is an event now. + assert!(!rpc.try_route(&json!({"reqId": "r-1"})).await); + } +} diff --git a/sidecar/src/store.rs b/sidecar/src/store.rs new file mode 100644 index 0000000..f863776 --- /dev/null +++ b/sidecar/src/store.rs @@ -0,0 +1,271 @@ +//! SQLite persistence: the event history, and the last thing the game said about itself. +//! +//! This is what lets the website read the past without asking the game, and what survives a sidecar +//! restart. The event loop writes every live event here as it broadcasts it; REST reads query here +//! instead of round-tripping the plugin. +//! +//! **The sidecar defines no schema for a frame's contents.** Events are persisted whole, as the +//! JSON text that arrived, with only `t` and `kind` lifted out for indexing. That is the +//! dumb-forwarder property doing real work: a protocol version that adds fields to an event needs +//! no change here, and only a version that adds a *new indexed column* ever needs a migration. + +use std::path::Path; + +use serde_json::Value; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; +use sqlx::{Row, SqlitePool}; +use tracing::info; + +const SCHEMA: &str = " +CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + t INTEGER NOT NULL, + kind TEXT NOT NULL, + json TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_events_kind_id ON events (kind, id DESC); +CREATE INDEX IF NOT EXISTS idx_events_t ON events (t); + +-- Exactly one row, id 1: the most recent server.hello. A board, in the sense chapter 4 uses the +-- word — current state with one producer, re-sent on every connect — rather than a history. +CREATE TABLE IF NOT EXISTS server_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + t INTEGER NOT NULL, + json TEXT NOT NULL +); +"; + +#[derive(Clone)] +pub struct Store { + pool: SqlitePool, +} + +impl Store { + /// Opens (creating if absent) the SQLite database and ensures the schema exists. + /// + /// `path` is a filesystem path, handed to sqlx as one. It is deliberately **not** formatted + /// into a `sqlite://` URL first: that spelling is parsed as a URL, so it percent-decodes the + /// path and splits it on `?`. Under an installed layout the path is absolute and chosen by the + /// operator — `C:\ProgramData\RunicGateway\rust-link.db`, or something under a home directory + /// with a `%` or `#` in it — and a URL round-trip silently opens a *different* file. + pub async fn open(path: &str) -> anyhow::Result { + // A service unit can name a data directory that does not exist yet; creating it here means + // one less way for a fresh install to fail on first start. + if let Some(dir) = Path::new(path).parent() { + if !dir.as_os_str().is_empty() && !dir.exists() { + std::fs::create_dir_all(dir)?; + } + } + + let opts = SqliteConnectOptions::new() + .filename(path) + .create_if_missing(true); + + // An in-memory database is **per connection**, not per process: every connection the pool + // opens gets its own empty one, so a second pooled connection finds none of the schema the + // first created. It presents as `no such table` from a random subset of queries, which is + // as confusing a failure as this file has. A single connection is the only coherent + // reading of `:memory:`, and it is what makes it usable at all. + let max = if is_in_memory(path) { 1 } else { 4 }; + + let pool = SqlitePoolOptions::new() + .max_connections(max) + .connect_with(opts) + .await?; + + sqlx::query(SCHEMA).execute(&pool).await?; + info!(%path, "store ready"); + Ok(Self { pool }) + } + + /// Cheap liveness check for the health endpoint. + pub async fn ping(&self) -> anyhow::Result<()> { + sqlx::query("SELECT 1").execute(&self.pool).await?; + Ok(()) + } + + /// Appends one live event. Failures are logged by the caller; persistence must never block the + /// live feed. + pub async fn insert_event(&self, t: i64, kind: &str, json: &str) -> anyhow::Result<()> { + sqlx::query("INSERT INTO events (t, kind, json) VALUES (?, ?, ?)") + .bind(t) + .bind(kind) + .bind(json) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Most-recent events, newest first, optionally filtered by kind. + pub async fn recent(&self, kind: Option<&str>, limit: i64) -> anyhow::Result> { + let limit = limit.clamp(1, 1000); + let rows = match kind { + Some(k) => { + sqlx::query("SELECT json FROM events WHERE kind = ? ORDER BY id DESC LIMIT ?") + .bind(k) + .bind(limit) + .fetch_all(&self.pool) + .await? + } + None => { + sqlx::query("SELECT json FROM events ORDER BY id DESC LIMIT ?") + .bind(limit) + .fetch_all(&self.pool) + .await? + } + }; + Ok(parse_json_column(rows)) + } + + /// Replaces the one `server_state` row. Called for every `server.hello`, which the plugin sends + /// on every connect — so this is an upsert by construction, not by accident. + pub async fn put_server_state(&self, t: i64, json: &str) -> anyhow::Result<()> { + sqlx::query( + "INSERT INTO server_state (id, t, json) VALUES (1, ?, ?) + ON CONFLICT(id) DO UPDATE SET t = excluded.t, json = excluded.json", + ) + .bind(t) + .bind(json) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// The last thing the game said about itself, or `None` if it has never connected. + /// + /// This is the read that makes the website render while the game is off, which is the whole + /// reason the sidecar holds a database at all. + pub async fn server_state(&self) -> anyhow::Result> { + let row = sqlx::query("SELECT json FROM server_state WHERE id = 1") + .fetch_optional(&self.pool) + .await?; + Ok(row.and_then(|r| serde_json::from_str(&r.get::("json")).ok())) + } +} + +/// Whether this path names an in-memory database rather than a file. Covers the bare `:memory:` +/// spelling and the `file:` URI form that carries `mode=memory`. +fn is_in_memory(path: &str) -> bool { + path == ":memory:" || (path.starts_with("file:") && path.contains("mode=memory")) +} + +fn parse_json_column(rows: Vec) -> Vec { + rows.into_iter() + .filter_map(|r| serde_json::from_str(&r.get::("json")).ok()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + async fn store() -> Store { + Store::open(":memory:").await.unwrap() + } + + /// The trap this file's pool sizing exists for: a multi-connection pool over `:memory:` hands + /// out empty databases. Asserting the *pool* is what makes the reason visible; asserting only + /// that a query works would pass again the moment someone "tidied" the sizing back. + #[tokio::test] + async fn an_in_memory_store_uses_exactly_one_connection() { + assert!(is_in_memory(":memory:")); + assert!(is_in_memory("file:x?mode=memory&cache=shared")); + assert!(!is_in_memory("rust-link.db")); + assert!(!is_in_memory("file:/var/lib/rg/rust-link.db")); + + let s = store().await; + assert_eq!(s.pool.options().get_max_connections(), 1); + } + + /// `sqlx::query` over a multi-statement string is the kind of thing that quietly runs only the + /// first statement. Both tables and both reads have to work on a real file, under the pool + /// size production uses. + #[tokio::test] + async fn the_whole_schema_is_created_on_a_pooled_file_store() { + let dir = std::env::temp_dir().join(format!("rust-link-test-{}", std::process::id())); + let path = dir.join("schema.db"); + let _ = std::fs::remove_dir_all(&dir); + + let s = Store::open(path.to_str().unwrap()).await.unwrap(); + assert_eq!(s.pool.options().get_max_connections(), 4); + + s.insert_event(1, "k", "{}").await.unwrap(); + s.put_server_state(1, "{}").await.unwrap(); + assert_eq!(s.recent(None, 10).await.unwrap().len(), 1); + assert!(s.server_state().await.unwrap().is_some()); + + drop(s); + let _ = std::fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn events_come_back_newest_first_and_filter_by_kind() { + let s = store().await; + s.insert_event(1, "server.hello", &json!({"n": 1}).to_string()) + .await + .unwrap(); + s.insert_event(2, "other", &json!({"n": 2}).to_string()) + .await + .unwrap(); + s.insert_event(3, "server.hello", &json!({"n": 3}).to_string()) + .await + .unwrap(); + + let all = s.recent(None, 10).await.unwrap(); + assert_eq!(all.len(), 3); + assert_eq!(all[0]["n"], 3); + + let hellos = s.recent(Some("server.hello"), 10).await.unwrap(); + assert_eq!(hellos.len(), 2); + assert_eq!(hellos[0]["n"], 3); + } + + /// An absent board reads as `None`, not as an empty object. A caller must be able to tell + /// "the game has never connected" from "the game connected and said nothing" — collapsing the + /// two is how a site ends up rendering a server that does not exist. + #[tokio::test] + async fn server_state_is_absent_until_a_hello_arrives() { + let s = store().await; + assert!(s.server_state().await.unwrap().is_none()); + + s.put_server_state(1, &json!({"serverId": "main", "players": 0}).to_string()) + .await + .unwrap(); + assert_eq!(s.server_state().await.unwrap().unwrap()["serverId"], "main"); + } + + /// The board holds exactly one row however many times the plugin reconnects. + #[tokio::test] + async fn a_second_hello_replaces_the_first() { + let s = store().await; + s.put_server_state(1, &json!({"bootId": "a"}).to_string()) + .await + .unwrap(); + s.put_server_state(2, &json!({"bootId": "b"}).to_string()) + .await + .unwrap(); + + assert_eq!(s.server_state().await.unwrap().unwrap()["bootId"], "b"); + let count: i64 = sqlx::query("SELECT COUNT(*) AS c FROM server_state") + .fetch_one(&s.pool) + .await + .unwrap() + .get("c"); + assert_eq!(count, 1); + } + + #[tokio::test] + async fn the_limit_is_clamped_rather_than_trusted() { + let s = store().await; + for i in 0..5 { + s.insert_event(i, "k", &json!({"i": i}).to_string()) + .await + .unwrap(); + } + // 0 and negatives would otherwise mean "no rows" and "SQLite's unlimited" respectively. + assert_eq!(s.recent(None, 0).await.unwrap().len(), 1); + assert_eq!(s.recent(None, -1).await.unwrap().len(), 1); + assert_eq!(s.recent(None, 100_000).await.unwrap().len(), 5); + } +} diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs new file mode 100644 index 0000000..ef040b1 --- /dev/null +++ b/sidecar/src/web.rs @@ -0,0 +1,422 @@ +//! The website-facing HTTP surface: a WebSocket live feed, and REST reads backed by the store. +//! +//! Two rules shape everything here, and both are inherited rather than invented: +//! +//! 1. **Authentication is always on.** `/health` is the only unauthenticated route, because +//! monitoring must be able to reach it. Everything else is behind a shared token that the config +//! generates on first run, so there is no state in which this process is listening without one. +//! 2. **Every response advertises the protocol version**, and a client that declares a different +//! one is refused `409` rather than served something it will mis-parse. A version mismatch is a +//! deployment fault, and it should look like one. +//! +//! The reads are store-backed on purpose. `/server` answers the last thing the game said about +//! itself even while the game is off, which is what lets the website render a server list during a +//! wipe or a restart. `/status` is the one route that round-trips the plugin, and it is the one +//! route that fails when the game is down — which is the honest answer to "what is it doing *right +//! now*". + +use std::sync::atomic::{AtomicI64, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use axum::{ + extract::ws::{Message, WebSocket, WebSocketUpgrade}, + extract::{Query, Request, State}, + http::{HeaderValue, StatusCode}, + middleware::{self, Next}, + response::{IntoResponse, Response}, + routing::get, + Json, Router, +}; +use serde::Deserialize; +use serde_json::{json, Value}; +use tokio::sync::broadcast; +use tracing::{debug, info, warn}; + +use crate::game::GameHandle; +use crate::rpc::{Rpc, RpcError}; +use crate::store::Store; +use crate::PROTOCOL_VERSION; + +/// The header every response carries, and the one a client may send to declare its own version. +pub const VERSION_HEADER: &str = "X-RustLink-Version"; + +/// Where the live feed lives. Named rather than spelled inline because `--print-config` reports it +/// to the installer, and the two must not drift. +pub const WS_PATH: &str = "/ws"; + +/// Shared state handed to each request handler. +#[derive(Clone)] +pub struct AppState { + pub events: broadcast::Sender, + pub game: GameHandle, + pub rpc: Rpc, + pub store: Store, + /// Shared secret the website must present. Always set (config guarantees non-empty). + pub token: Arc, + pub started: Instant, + /// Epoch ms of the last line received from the plugin, 0 if none yet. + pub last_event: Arc, +} + +pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { + let protected = Router::new() + .route(WS_PATH, get(ws_upgrade)) + // The board: the last `server.hello`. Store-backed, so it answers while the game is off. + .route("/server", get(server_board)) + // Event history, newest first, optionally filtered by kind. + .route("/events", get(events)) + // Live: a correlated round trip to the plugin. Fails when the game is down, by design. + .route("/status", get(status)) + .route_layer(middleware::from_fn_with_state(state.clone(), gate)); + + let app = Router::new() + .route("/health", get(health)) + .merge(protected) + // Every response advertises the protocol version, so a client can notice a mismatch even + // on /health or on an error response. + .layer(middleware::from_fn(version_header)) + .with_state(state); + + let listener = tokio::net::TcpListener::bind(addr).await?; + info!(addr = %listener.local_addr()?, "web server listening"); + axum::serve(listener, app).await?; + Ok(()) +} + +// ---- health ---- + +/// Protocol version, whether the plugin is connected, database reachability, uptime, and when the +/// plugin last sent anything. Unauthenticated, so monitoring can reach it. +async fn health(State(st): State) -> impl IntoResponse { + let plugin = st.game.is_connected().await; + let db_ok = st.store.ping().await.is_ok(); + let last_ms = st.last_event.load(Ordering::Relaxed); + + let status = if plugin && db_ok { "ok" } else { "degraded" }; + + Json(json!({ + "status": status, + "protocol": PROTOCOL_VERSION, + "plugin_connected": plugin, + "database": if db_ok { "ok" } else { "error" }, + "uptime": format_uptime(st.started.elapsed()), + "last_event": iso_ms(last_ms), + })) +} + +fn format_uptime(d: Duration) -> String { + let secs = d.as_secs(); + let (days, hours, mins) = (secs / 86400, (secs % 86400) / 3600, (secs % 3600) / 60); + if days > 0 { + format!("{days}d {hours}h") + } else if hours > 0 { + format!("{hours}h {mins}m") + } else { + format!("{mins}m") + } +} + +fn iso_ms(ms: i64) -> Option { + if ms <= 0 { + return None; + } + chrono::DateTime::from_timestamp_millis(ms) + .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()) +} + +// ---- reads ---- + +/// The last `server.hello`, or `204` if the game has never connected. +/// +/// `204` rather than `200` with a null: "we have never heard from this server" and "this server +/// reports nothing" are different answers, and a client that cannot tell them apart renders a +/// server that does not exist. +async fn server_board(State(st): State) -> Response { + match st.store.server_state().await { + Ok(Some(v)) => Json(v).into_response(), + Ok(None) => StatusCode::NO_CONTENT.into_response(), + Err(e) => { + warn!(error = %e, "server board read failed"); + store_error() + } + } +} + +#[derive(Debug, Deserialize)] +struct EventsQuery { + kind: Option, + limit: Option, +} + +async fn events(State(st): State, Query(q): Query) -> Response { + match st + .store + .recent(q.kind.as_deref(), q.limit.unwrap_or(50)) + .await + { + Ok(rows) => Json(json!({ "events": rows })).into_response(), + Err(e) => { + warn!(error = %e, "event read failed"); + store_error() + } + } +} + +fn store_error() -> Response { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "store read failed"})), + ) + .into_response() +} + +/// A correlated round trip to the plugin: what the server is doing right now. +async fn status(State(st): State) -> Response { + let req_id = st.rpc.next_req_id(); + let command = json!({ "cmd": "server.status", "reqId": req_id }); + respond(st.rpc.call(&st.game, command, &req_id).await) +} + +/// Maps an RPC outcome onto a status code. +/// +/// The two failures are deliberately different codes. `NoPlugin` is `503`: the game is down and the +/// caller should stop asking for a moment. `Timeout` is `504`: the game is up and did not answer in +/// time, which is a different operational problem with a different fix. +fn respond(result: Result) -> Response { + match result { + Ok(v) => Json(v).into_response(), + Err(RpcError::NoPlugin) => ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({"error": "no plugin connected"})), + ) + .into_response(), + Err(RpcError::Timeout) => ( + StatusCode::GATEWAY_TIMEOUT, + Json(json!({"error": "the plugin did not reply in time"})), + ) + .into_response(), + } +} + +// ---- gate: protocol check + auth ---- + +/// Adds the version header to every response. +async fn version_header(req: Request, next: Next) -> Response { + let mut resp = next.run(req).await; + if let Ok(v) = HeaderValue::from_str(&PROTOCOL_VERSION.to_string()) { + resp.headers_mut().insert(VERSION_HEADER, v); + } + resp +} + +/// Guards every non-health route: first a protocol-version check (if the client declares one), then +/// authentication. The token may arrive as `Authorization: Bearer `, `X-Api-Key: `, or +/// `?token=` (the last so browser WebSocket clients, which cannot set handshake headers, still +/// authenticate). The token compare is constant-time. +async fn gate(State(st): State, req: Request, next: Next) -> Response { + // Protocol version: if the client states one and it disagrees, fail loudly and specifically. + if let Some(v) = req + .headers() + .get(VERSION_HEADER.to_ascii_lowercase().as_str()) + .and_then(|h| h.to_str().ok()) + { + if v.trim() != PROTOCOL_VERSION.to_string() { + return ( + StatusCode::CONFLICT, + Json(json!({ + "error": "protocol version mismatch", + "sidecar_protocol": PROTOCOL_VERSION, + "client_protocol": v.trim(), + })), + ) + .into_response(); + } + } + + match extract_token(&req) { + Some(t) if constant_time_eq(t.as_bytes(), st.token.as_bytes()) => next.run(req).await, + _ => ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "missing or invalid auth token"})), + ) + .into_response(), + } +} + +fn extract_token(req: &Request) -> Option { + // Authorization: Bearer + if let Some(v) = req + .headers() + .get("authorization") + .and_then(|h| h.to_str().ok()) + { + if let Some(rest) = v + .strip_prefix("Bearer ") + .or_else(|| v.strip_prefix("bearer ")) + { + return Some(rest.trim().to_string()); + } + } + // X-Api-Key: + if let Some(v) = req.headers().get("x-api-key").and_then(|h| h.to_str().ok()) { + return Some(v.trim().to_string()); + } + // ?token= + if let Some(q) = req.uri().query() { + for pair in q.split('&') { + if let Some(v) = pair.strip_prefix("token=") { + return Some(v.to_string()); + } + } + } + None +} + +/// Length-independent, early-return-free comparison, so a wrong token leaks no timing signal. +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 +} + +// ---- live feed ---- + +async fn ws_upgrade(ws: WebSocketUpgrade, State(state): State) -> impl IntoResponse { + ws.on_upgrade(move |socket| ws_client(socket, state)) +} + +async fn ws_client(mut socket: WebSocket, state: AppState) { + let mut rx = state.events.subscribe(); + info!("ws client connected"); + + let hello = json!({"kind": "ws.hello", "protocol": PROTOCOL_VERSION}).to_string(); + if socket.send(Message::Text(hello)).await.is_err() { + return; + } + + loop { + tokio::select! { + recv = rx.recv() => { + match recv { + Ok(line) => { + if socket.send(Message::Text(line)).await.is_err() { + break; + } + } + Err(broadcast::error::RecvError::Lagged(n)) => { + warn!(skipped = n, "ws client lagged; dropping missed events"); + } + Err(broadcast::error::RecvError::Closed) => break, + } + } + msg = socket.recv() => { + match msg { + Some(Ok(Message::Close(_))) | None => break, + Some(Ok(Message::Ping(p))) => { let _ = socket.send(Message::Pong(p)).await; } + Some(Ok(other)) => debug!(?other, "ws client message ignored"), + Some(Err(e)) => { debug!(error = %e, "ws client error"); break; } + } + } + _ = tokio::time::sleep(Duration::from_secs(30)) => { + if socket.send(Message::Ping(Vec::new())).await.is_err() { + break; + } + } + } + } + + info!("ws client disconnected"); +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + + fn req_with(headers: &[(&str, &str)], uri: &str) -> Request { + let mut b = Request::builder().uri(uri); + for (k, v) in headers { + b = b.header(*k, *v); + } + b.body(Body::empty()).unwrap() + } + + #[test] + fn the_token_is_read_from_all_three_places() { + assert_eq!( + extract_token(&req_with(&[("authorization", "Bearer abc")], "/events")).as_deref(), + Some("abc") + ); + assert_eq!( + extract_token(&req_with(&[("authorization", "bearer abc")], "/events")).as_deref(), + Some("abc") + ); + assert_eq!( + extract_token(&req_with(&[("x-api-key", "abc")], "/events")).as_deref(), + Some("abc") + ); + // The query form exists for browser WebSocket clients, which cannot set a handshake header. + assert_eq!( + extract_token(&req_with(&[], "/ws?token=abc")).as_deref(), + Some("abc") + ); + assert_eq!(extract_token(&req_with(&[], "/events")), None); + } + + /// `Authorization: abc` with no scheme is not a token. Accepting it would make the header's + /// grammar optional, and a caller that got it wrong would work here and nowhere else. + #[test] + fn a_bare_authorization_value_is_not_a_token() { + assert_eq!( + extract_token(&req_with(&[("authorization", "abc")], "/x")), + None + ); + } + + #[test] + fn constant_time_eq_still_compares_correctly() { + assert!(constant_time_eq(b"abc", b"abc")); + assert!(!constant_time_eq(b"abc", b"abd")); + assert!(!constant_time_eq(b"abc", b"abcd")); + assert!(constant_time_eq(b"", b"")); + } + + /// The two RPC failures must not collapse into one code: "the game is down" and "the game is up + /// and slow" have different fixes, and the website's client branches on the status. + #[test] + fn rpc_failures_map_to_distinct_codes() { + assert_eq!( + respond(Err(RpcError::NoPlugin)).status(), + StatusCode::SERVICE_UNAVAILABLE + ); + assert_eq!( + respond(Err(RpcError::Timeout)).status(), + StatusCode::GATEWAY_TIMEOUT + ); + assert_eq!(respond(Ok(json!({"ok": true}))).status(), StatusCode::OK); + } + + #[test] + fn uptime_reads_as_a_human_would_write_it() { + assert_eq!(format_uptime(Duration::from_secs(90)), "1m"); + assert_eq!(format_uptime(Duration::from_secs(3 * 3600 + 120)), "3h 2m"); + assert_eq!( + format_uptime(Duration::from_secs(2 * 86400 + 3600)), + "2d 1h" + ); + } + + /// A zero timestamp means "never", and must not render as 1970. + #[test] + fn a_never_timestamp_is_none_rather_than_the_epoch() { + assert_eq!(iso_ms(0), None); + assert_eq!(iso_ms(-1), None); + assert!(iso_ms(1_757_000_000_000).unwrap().starts_with("2025-")); + } +}