From 57190891d2999ea1c4afd896a271a70b93f2da12 Mon Sep 17 00:00:00 2001 From: KeyCode17 Date: Mon, 18 May 2026 06:02:00 +0700 Subject: [PATCH] feat(cli): complete Phase 04 subcommand surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split px-cli into module-per-command so each file stays well under the 200-LOC axum-best-practice rule, then fill in the gaps the Phase 04 plan tracks: - `allowlist add --handler cloudflare` — ADR-0023 follow-up. Operator can mark a target as CF-fronted directly in the YAML without the deprecated PX_CAMOUFOX_DOMAINS env CSV. - `allowlist remove --domain ...` — was missing entirely; the phase plan lists `{add,remove,list}`. - `allowlist list` now also prints the handler column. - `keys generate --write` — append the generated id/argon2_hash to config/keys.yaml in addition to printing (with duplicate-id guard). - `detect --url ...` — fetch the URL with a stealth-ish UA before running the regex detector. Non-server errors keep the body so PX block pages still surface `Detected::Yes`. - `solve ` — call POST /v1/solve on a running px-server with a Bearer id:secret token (env PX_SERVER_URL / PX_API_KEY supported). Adds reqwest + serde_json to the workspace and the `env` feature to clap so api_key / server URL can come from the environment. Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 508 ++++++++++++++++++++++++++++++- Cargo.toml | 3 +- px-cli/Cargo.toml | 2 + px-cli/src/cli.rs | 93 ++++++ px-cli/src/commands/allowlist.rs | 92 ++++++ px-cli/src/commands/detect.rs | 58 ++++ px-cli/src/commands/keys.rs | 88 ++++++ px-cli/src/commands/mod.rs | 5 + px-cli/src/commands/serve.rs | 9 + px-cli/src/commands/solve.rs | 58 ++++ px-cli/src/main.rs | 176 +---------- 11 files changed, 913 insertions(+), 179 deletions(-) create mode 100644 px-cli/src/cli.rs create mode 100644 px-cli/src/commands/allowlist.rs create mode 100644 px-cli/src/commands/detect.rs create mode 100644 px-cli/src/commands/keys.rs create mode 100644 px-cli/src/commands/mod.rs create mode 100644 px-cli/src/commands/serve.rs create mode 100644 px-cli/src/commands/solve.rs diff --git a/Cargo.lock b/Cargo.lock index 0921ea9..f688e7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -47,7 +53,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -58,7 +64,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -79,6 +85,18 @@ dependencies = [ "password-hash", ] +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -259,6 +277,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chromiumoxide" version = "0.9.1" @@ -275,7 +299,7 @@ dependencies = [ "futures", "futures-timer", "pin-project-lite", - "reqwest", + "reqwest 0.13.3", "serde", "serde_json", "thiserror 2.0.18", @@ -370,6 +394,23 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + [[package]] name = "cookie" version = "0.16.2" @@ -425,6 +466,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -517,7 +567,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -555,6 +605,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -695,6 +755,19 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -702,9 +775,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -824,6 +899,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http 1.4.0", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + [[package]] name = "hyper-tls" version = "0.6.0" @@ -1145,6 +1236,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -1172,6 +1269,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.0" @@ -1180,7 +1287,7 @@ checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1206,7 +1313,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1364,7 +1471,9 @@ dependencies = [ "clap", "pxsolver-auth", "pxsolver-detector", + "reqwest 0.12.28", "serde", + "serde_json", "serde_yaml", "tokio", "uuid", @@ -1576,6 +1685,61 @@ dependencies = [ "serde_json", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -1670,6 +1834,44 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http 1.4.0", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "reqwest" version = "0.13.3" @@ -1699,6 +1901,26 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustix" version = "1.1.4" @@ -1709,7 +1931,42 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -1739,7 +1996,7 @@ version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1892,6 +2149,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "slab" version = "0.4.12" @@ -1911,7 +2174,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1973,7 +2236,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2075,6 +2338,21 @@ dependencies = [ "zerovec 0.11.6", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.52.3" @@ -2088,7 +2366,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2112,6 +2390,29 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "tower" version = "0.5.3" @@ -2134,12 +2435,17 @@ version = "0.6.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" dependencies = [ + "async-compression", "bitflags", "bytes", + "futures-core", "futures-util", "http 1.4.0", "http-body", + "http-body-util", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", @@ -2268,6 +2574,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -2470,6 +2782,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webdriver" version = "0.53.0" @@ -2490,6 +2812,15 @@ dependencies = [ "url", ] +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "which" version = "8.0.2" @@ -2505,7 +2836,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2543,6 +2874,24 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -2552,6 +2901,135 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -2757,6 +3235,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/Cargo.toml b/Cargo.toml index a563a6f..1331a20 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,10 +62,11 @@ chromiumoxide = { version = "0.9" } fantoccini = "0.22" futures = "0.3" regex = "1.11" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "gzip"] } subtle = "2.6" url = "2.5" axum = { version = "0.8", features = ["macros"] } -clap = { version = "4.5", features = ["derive"] } +clap = { version = "4.5", features = ["derive", "env"] } dashmap = "6.1" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/px-cli/Cargo.toml b/px-cli/Cargo.toml index a073085..42e12bd 100644 --- a/px-cli/Cargo.toml +++ b/px-cli/Cargo.toml @@ -22,7 +22,9 @@ argon2 = { workspace = true } clap = { workspace = true } px-auth = { workspace = true } px-detector = { workspace = true } +reqwest = { workspace = true } serde = { workspace = true } +serde_json = { workspace = true } serde_yaml = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread", "fs", "io-std"] } uuid = { workspace = true } diff --git a/px-cli/src/cli.rs b/px-cli/src/cli.rs new file mode 100644 index 0000000..388b182 --- /dev/null +++ b/px-cli/src/cli.rs @@ -0,0 +1,93 @@ +use clap::{Args, Parser, Subcommand}; +use std::path::PathBuf; + +#[derive(Parser, Debug)] +#[command(name = "px-cli", about = "Operator CLI for px-solver")] +pub struct Cli { + #[command(subcommand)] + pub cmd: Cmd, +} + +#[derive(Subcommand, Debug)] +pub enum Cmd { + /// Run the PerimeterX detector against a URL or stdin HTML. + Detect(DetectArgs), + /// Manage API keys. + Keys { + #[command(subcommand)] + op: KeysCmd, + }, + /// Manage the per-domain allowlist. + Allowlist { + #[command(subcommand)] + op: AllowlistCmd, + }, + /// Convenience pointer to `cargo run -p px-server`. + Serve, + /// Solve a target URL by calling a running px-server's POST /v1/solve. + Solve(SolveArgs), +} + +#[derive(Args, Debug)] +pub struct DetectArgs { + /// URL to fetch and inspect. If absent, HTML is read from stdin. + #[arg(long)] + pub url: Option, +} + +#[derive(Subcommand, Debug)] +pub enum KeysCmd { + /// Generate a new key id + secret + argon2 hash. + Generate { + #[arg(long)] + id: String, + #[arg(long)] + note: Option, + /// If set, append the generated hash to the keys file. + #[arg(long, default_value = "config/keys.yaml")] + path: PathBuf, + /// Append to the keys file instead of just printing. + #[arg(long)] + write: bool, + }, +} + +#[derive(Subcommand, Debug)] +pub enum AllowlistCmd { + List { + #[arg(long, default_value = "config/allowlist.yaml")] + path: PathBuf, + }, + Add { + #[arg(long, default_value = "config/allowlist.yaml")] + path: PathBuf, + #[arg(long)] + domain: String, + #[arg(long)] + justification: String, + /// Optional handler routing hint, e.g. `cloudflare` (ADR-0023). + #[arg(long)] + handler: Option, + }, + Remove { + #[arg(long, default_value = "config/allowlist.yaml")] + path: PathBuf, + #[arg(long)] + domain: String, + }, +} + +#[derive(Args, Debug)] +pub struct SolveArgs { + /// Target URL to solve. + pub url: String, + /// Base URL of a running px-server. + #[arg(long, env = "PX_SERVER_URL", default_value = "http://127.0.0.1:8080")] + pub server: String, + /// API key as `id:secret`. + #[arg(long, env = "PX_API_KEY")] + pub api_key: String, + /// Optional upstream proxy passed to the solver. + #[arg(long)] + pub proxy: Option, +} diff --git a/px-cli/src/commands/allowlist.rs b/px-cli/src/commands/allowlist.rs new file mode 100644 index 0000000..4f0224b --- /dev/null +++ b/px-cli/src/commands/allowlist.rs @@ -0,0 +1,92 @@ +use anyhow::{Context, Result, bail}; +use px_auth::{AllowlistEntry, AllowlistStore, YamlAllowlistStore}; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +use crate::cli::AllowlistCmd; + +#[derive(Debug, Default, Deserialize, Serialize)] +struct AllowlistFile { + entries: Vec, +} + +pub async fn run(op: AllowlistCmd) -> Result<()> { + match op { + AllowlistCmd::List { path } => list(&path).await, + AllowlistCmd::Add { + path, + domain, + justification, + handler, + } => { + let entry = AllowlistEntry { + domain, + tos_reviewed: true, + justification, + handler, + }; + entry + .validate() + .map_err(|e| anyhow::anyhow!("invalid entry: {e}"))?; + add(&path, entry).await + } + AllowlistCmd::Remove { path, domain } => remove(&path, &domain).await, + } +} + +async fn list(path: &Path) -> Result<()> { + let store = YamlAllowlistStore::load(path) + .await + .with_context(|| format!("load {}", path.display()))?; + for entry in store + .list() + .await + .map_err(|e| anyhow::anyhow!("list: {e}"))? + { + let handler = entry.handler.as_deref().unwrap_or("-"); + println!( + "{}\t{}\t{}\t{}", + entry.domain, entry.tos_reviewed, handler, entry.justification + ); + } + Ok(()) +} + +async fn add(path: &Path, entry: AllowlistEntry) -> Result<()> { + let mut file = read_or_empty(path).await?; + if file.entries.iter().any(|e| e.domain == entry.domain) { + bail!( + "domain '{}' already present in {}", + entry.domain, + path.display() + ); + } + file.entries.push(entry); + write(path, &file).await +} + +async fn remove(path: &Path, domain: &str) -> Result<()> { + let mut file = read_or_empty(path).await?; + let before = file.entries.len(); + file.entries.retain(|e| e.domain != domain); + if file.entries.len() == before { + bail!("domain '{}' not found in {}", domain, path.display()); + } + write(path, &file).await +} + +async fn read_or_empty(path: &Path) -> Result { + if !path.exists() { + return Ok(AllowlistFile::default()); + } + let bytes = tokio::fs::read(path).await.context("read allowlist")?; + serde_yaml::from_slice(&bytes).context("parse allowlist") +} + +async fn write(path: &Path, file: &AllowlistFile) -> Result<()> { + let bytes = serde_yaml::to_string(file).context("serialize allowlist")?; + tokio::fs::write(path, bytes) + .await + .context("write allowlist")?; + Ok(()) +} diff --git a/px-cli/src/commands/detect.rs b/px-cli/src/commands/detect.rs new file mode 100644 index 0000000..141cffb --- /dev/null +++ b/px-cli/src/commands/detect.rs @@ -0,0 +1,58 @@ +use anyhow::{Context, Result, bail}; +use px_detector::{Detected, Detector, RegexDetector}; +use tokio::io::AsyncReadExt; + +use crate::cli::DetectArgs; + +pub async fn run(args: DetectArgs) -> Result<()> { + let html = match args.url { + Some(url) => fetch_html(&url).await?, + None => read_stdin_html().await?, + }; + match RegexDetector::new().detect(&html) { + Detected::Yes(d) => { + let serialized = serde_yaml::to_string(&d).context("serialize detection")?; + println!("{serialized}"); + Ok(()) + } + Detected::No => bail!("no PerimeterX markers detected on input"), + } +} + +async fn read_stdin_html() -> Result { + let mut buf = Vec::new(); + tokio::io::stdin() + .read_to_end(&mut buf) + .await + .context("read stdin")?; + Ok(String::from_utf8_lossy(&buf).into_owned()) +} + +async fn fetch_html(url: &str) -> Result { + let client = reqwest::Client::builder() + .gzip(true) + .user_agent(default_user_agent()) + .build() + .context("build http client")?; + let resp = client + .get(url) + .send() + .await + .with_context(|| format!("GET {url}"))?; + let status = resp.status(); + let body = resp.text().await.context("read response body")?; + // PX block pages return 4xx with the markers embedded in the body. Keep + // the body so the detector can still surface `Detected::Yes`; bail only + // on hard transport errors (5xx, redirects we already followed, etc.). + if status.is_server_error() { + bail!("upstream returned HTTP {status} for {url}"); + } + Ok(body) +} + +fn default_user_agent() -> &'static str { + concat!( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ", + "(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36" + ) +} diff --git a/px-cli/src/commands/keys.rs b/px-cli/src/commands/keys.rs new file mode 100644 index 0000000..4c390cc --- /dev/null +++ b/px-cli/src/commands/keys.rs @@ -0,0 +1,88 @@ +use anyhow::{Context, Result}; +use argon2::Argon2; +use argon2::password_hash::{PasswordHasher, SaltString}; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +use crate::cli::KeysCmd; + +#[derive(Debug, Default, Deserialize, Serialize)] +struct KeysFile { + #[serde(default)] + keys: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +struct KeyEntry { + id: String, + argon2_hash: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + note: Option, +} + +pub async fn run(op: KeysCmd) -> Result<()> { + let KeysCmd::Generate { + id, + note, + path, + write, + } = op; + let (secret, hash) = generate_secret_and_hash()?; + println!("id: {id}"); + println!("secret: {secret}"); + println!("argon2_hash: {hash}"); + if let Some(n) = ¬e { + println!("note: {n}"); + } + if write { + append_key( + &path, + KeyEntry { + id, + argon2_hash: hash, + note, + }, + ) + .await?; + println!("written: {}", path.display()); + } else { + println!( + "(not persisted; re-run with --write to append to {})", + path.display() + ); + } + Ok(()) +} + +fn generate_secret_and_hash() -> Result<(String, String)> { + let secret = uuid::Uuid::new_v4().simple().to_string(); + let salt = + SaltString::encode_b64(secret.as_bytes()).map_err(|e| anyhow::anyhow!("salt: {e}"))?; + let hash = Argon2::default() + .hash_password(secret.as_bytes(), &salt) + .map_err(|e| anyhow::anyhow!("hash: {e}"))? + .to_string(); + Ok((secret, hash)) +} + +async fn append_key(path: &Path, entry: KeyEntry) -> Result<()> { + let mut file: KeysFile = if path.exists() { + let bytes = tokio::fs::read(path).await.context("read keys file")?; + serde_yaml::from_slice(&bytes).context("parse keys file")? + } else { + KeysFile::default() + }; + if file.keys.iter().any(|k| k.id == entry.id) { + anyhow::bail!( + "key id '{}' already present in {}", + entry.id, + path.display() + ); + } + file.keys.push(entry); + let bytes = serde_yaml::to_string(&file).context("serialize keys file")?; + tokio::fs::write(path, bytes) + .await + .context("write keys file")?; + Ok(()) +} diff --git a/px-cli/src/commands/mod.rs b/px-cli/src/commands/mod.rs new file mode 100644 index 0000000..d8e0ed2 --- /dev/null +++ b/px-cli/src/commands/mod.rs @@ -0,0 +1,5 @@ +pub mod allowlist; +pub mod detect; +pub mod keys; +pub mod serve; +pub mod solve; diff --git a/px-cli/src/commands/serve.rs b/px-cli/src/commands/serve.rs new file mode 100644 index 0000000..16bdbfe --- /dev/null +++ b/px-cli/src/commands/serve.rs @@ -0,0 +1,9 @@ +use anyhow::Result; + +pub fn run() -> Result<()> { + println!( + "px-cli serve is a thin pointer; run the px-server binary directly: \ + `cargo run -p px-server` (or `px-server` if installed)." + ); + Ok(()) +} diff --git a/px-cli/src/commands/solve.rs b/px-cli/src/commands/solve.rs new file mode 100644 index 0000000..cce7588 --- /dev/null +++ b/px-cli/src/commands/solve.rs @@ -0,0 +1,58 @@ +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; + +use crate::cli::SolveArgs; + +#[derive(Debug, Serialize)] +struct SolveRequest<'a> { + url: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + proxy: Option<&'a str>, +} + +#[derive(Debug, Deserialize)] +struct SolveEnvelope { + data: serde_json::Value, + #[serde(default)] + status: Option, +} + +pub async fn run(args: SolveArgs) -> Result<()> { + let SolveArgs { + url, + server, + api_key, + proxy, + } = args; + if !api_key.contains(':') { + bail!("--api-key (or PX_API_KEY) must be in the form `id:secret`"); + } + let endpoint = format!("{}/v1/solve", server.trim_end_matches('/')); + let body = SolveRequest { + url: &url, + proxy: proxy.as_deref(), + }; + let client = reqwest::Client::builder() + .build() + .context("build http client")?; + let resp = client + .post(&endpoint) + .bearer_auth(api_key) + .json(&body) + .send() + .await + .with_context(|| format!("POST {endpoint}"))?; + let status = resp.status(); + let text = resp.text().await.context("read response body")?; + if !status.is_success() { + bail!("px-server returned HTTP {status}: {text}"); + } + let envelope: SolveEnvelope = + serde_json::from_str(&text).context("parse SingleResponse")?; + let pretty = serde_json::to_string_pretty(&envelope.data).context("serialize data")?; + println!("{pretty}"); + if let Some(s) = envelope.status.as_deref() { + eprintln!("status: {s}"); + } + Ok(()) +} diff --git a/px-cli/src/main.rs b/px-cli/src/main.rs index 598d057..28e529b 100644 --- a/px-cli/src/main.rs +++ b/px-cli/src/main.rs @@ -1,174 +1,18 @@ -use anyhow::{Context, Result, bail}; -use argon2::Argon2; -use argon2::password_hash::{PasswordHasher, SaltString}; -use clap::{Parser, Subcommand}; -use px_auth::{AllowlistEntry, AllowlistStore, YamlAllowlistStore}; -use px_detector::{Detected, Detector, RegexDetector}; -use std::path::PathBuf; -use tokio::io::AsyncReadExt; +mod cli; +mod commands; -#[derive(Parser, Debug)] -#[command(name = "px-cli", about = "Operator CLI for px-solver")] -struct Cli { - #[command(subcommand)] - cmd: Cmd, -} - -#[derive(Subcommand, Debug)] -enum Cmd { - Detect, - Keys { - #[command(subcommand)] - op: KeysCmd, - }, - Allowlist { - #[command(subcommand)] - op: AllowlistCmd, - }, - Serve, - Solve { - url: String, - }, -} +use anyhow::Result; +use clap::Parser; -#[derive(Subcommand, Debug)] -enum KeysCmd { - Generate { - #[arg(long)] - id: String, - #[arg(long)] - note: Option, - }, -} - -#[derive(Subcommand, Debug)] -enum AllowlistCmd { - List { - #[arg(long, default_value = "config/allowlist.yaml")] - path: PathBuf, - }, - Add { - #[arg(long, default_value = "config/allowlist.yaml")] - path: PathBuf, - #[arg(long)] - domain: String, - #[arg(long)] - justification: String, - }, -} +use crate::cli::{Cli, Cmd}; #[tokio::main] async fn main() -> Result<()> { match Cli::parse().cmd { - Cmd::Detect => detect_from_stdin().await, - Cmd::Keys { op } => keys(op).await, - Cmd::Allowlist { op } => allowlist(op).await, - Cmd::Serve => serve_stub(), - Cmd::Solve { url } => solve_stub(&url), - } -} - -async fn detect_from_stdin() -> Result<()> { - let mut buf = Vec::new(); - tokio::io::stdin() - .read_to_end(&mut buf) - .await - .context("read stdin")?; - let html = String::from_utf8_lossy(&buf); - let detector = RegexDetector::new(); - match detector.detect(&html) { - Detected::Yes(d) => { - println!( - "{}", - serde_yaml::to_string(&d).context("serialize detection")? - ); - Ok(()) - } - Detected::No => bail!("no PerimeterX markers detected on input"), - } -} - -async fn keys(op: KeysCmd) -> Result<()> { - let KeysCmd::Generate { id, note } = op; - let secret = uuid::Uuid::new_v4().simple().to_string(); - let salt = - SaltString::encode_b64(secret.as_bytes()).map_err(|e| anyhow::anyhow!("salt: {e}"))?; - let hash = Argon2::default() - .hash_password(secret.as_bytes(), &salt) - .map_err(|e| anyhow::anyhow!("hash: {e}"))? - .to_string(); - println!("id: {id}"); - println!("secret: {secret}"); - println!("argon2_hash: {hash}"); - if let Some(n) = note { - println!("note: {n}"); - } - Ok(()) -} - -async fn allowlist(op: AllowlistCmd) -> Result<()> { - match op { - AllowlistCmd::List { path } => { - let store = YamlAllowlistStore::load(&path) - .await - .with_context(|| format!("load {}", path.display()))?; - for entry in store - .list() - .await - .map_err(|e| anyhow::anyhow!("list: {e}"))? - { - println!( - "{}\t{}\t{}", - entry.domain, entry.tos_reviewed, entry.justification - ); - } - Ok(()) - } - AllowlistCmd::Add { - path, - domain, - justification, - } => { - let entry = AllowlistEntry { - domain, - tos_reviewed: true, - justification, - handler: None, - }; - append_allowlist(&path, entry).await - } + Cmd::Detect(args) => commands::detect::run(args).await, + Cmd::Keys { op } => commands::keys::run(op).await, + Cmd::Allowlist { op } => commands::allowlist::run(op).await, + Cmd::Serve => commands::serve::run(), + Cmd::Solve(args) => commands::solve::run(args).await, } } - -async fn append_allowlist(path: &std::path::Path, entry: AllowlistEntry) -> Result<()> { - #[derive(Debug, serde::Deserialize, serde::Serialize, Default)] - struct File { - entries: Vec, - } - let mut file: File = if path.exists() { - let bytes = tokio::fs::read(path).await.context("read allowlist")?; - serde_yaml::from_slice(&bytes).context("parse allowlist")? - } else { - File::default() - }; - file.entries.push(entry); - let bytes = serde_yaml::to_string(&file).context("serialize allowlist")?; - tokio::fs::write(path, bytes) - .await - .context("write allowlist")?; - Ok(()) -} - -fn serve_stub() -> Result<()> { - println!( - "px-cli serve is a thin wrapper; run the px-server binary directly: `cargo run -p px-server`" - ); - Ok(()) -} - -fn solve_stub(url: &str) -> Result<()> { - println!( - "px-cli solve is not bound to a running server in this build; POST to /v1/solve at the server with {{\"url\":\"{url}\"}}" - ); - Ok(()) -}