Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
508 changes: 496 additions & 12 deletions Cargo.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions px-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
93 changes: 93 additions & 0 deletions px-cli/src/cli.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
}

#[derive(Subcommand, Debug)]
pub enum KeysCmd {
/// Generate a new key id + secret + argon2 hash.
Generate {
#[arg(long)]
id: String,
#[arg(long)]
note: Option<String>,
/// 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<String>,
},
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<String>,
}
92 changes: 92 additions & 0 deletions px-cli/src/commands/allowlist.rs
Original file line number Diff line number Diff line change
@@ -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<AllowlistEntry>,
}

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<AllowlistFile> {
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(())
}
58 changes: 58 additions & 0 deletions px-cli/src/commands/detect.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
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<String> {
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"
)
}
88 changes: 88 additions & 0 deletions px-cli/src/commands/keys.rs
Original file line number Diff line number Diff line change
@@ -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<KeyEntry>,
}

#[derive(Debug, Deserialize, Serialize)]
struct KeyEntry {
id: String,
argon2_hash: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
}

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) = &note {
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(())
}
5 changes: 5 additions & 0 deletions px-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
pub mod allowlist;
pub mod detect;
pub mod keys;
pub mod serve;
pub mod solve;
9 changes: 9 additions & 0 deletions px-cli/src/commands/serve.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
Loading
Loading