use std::io::Read;
use boatramp_core::access::{BasicAuth, RateLimit};
use clap::Subcommand;
use crate::client;
use crate::config::ProjectConfig;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("empty password")]
EmptyPassword,
#[error("rps must be > 0")]
RpsZero,
#[error(transparent)]
Client(#[from] crate::client::ClientError),
#[error(transparent)]
Io(#[from] std::io::Error),
}
type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, clap::Args)]
pub struct AccessArgs {
#[arg(long, env = "BOATRAMP_SERVER", global = true)]
server: Option<String>,
#[arg(long, env = "BOATRAMP_SITE", global = true)]
site: Option<String>,
#[command(subcommand)]
command: AccessCommand,
}
#[derive(Debug, Subcommand)]
enum AccessCommand {
Show,
BasicAuth {
#[command(subcommand)]
command: BasicAuthCommand,
},
Ip {
#[command(subcommand)]
command: IpCommand,
},
RateLimit {
#[command(subcommand)]
command: RateLimitCommand,
},
TrustedProxy {
#[command(subcommand)]
command: TrustedProxyCommand,
},
Waf {
#[command(subcommand)]
command: WafCommand,
},
}
#[derive(Debug, Subcommand)]
enum WafCommand {
UserAgent {
#[arg(long = "deny", value_name = "PATTERN")]
deny: Vec<String>,
#[arg(long = "allow", value_name = "PATTERN")]
allow: Vec<String>,
},
UserAgentOff,
Anomaly {
#[arg(long)]
threshold: Option<u32>,
},
AnomalyOff,
}
#[derive(Debug, Subcommand)]
enum BasicAuthCommand {
Add {
user: String,
#[arg(long)]
password: Option<String>,
#[arg(long)]
realm: Option<String>,
},
Rm {
user: String,
},
Clear,
}
#[derive(Debug, Subcommand)]
enum IpCommand {
Allow {
cidr: String,
},
Deny {
cidr: String,
},
Clear,
}
#[derive(Debug, Subcommand)]
enum RateLimitCommand {
Set {
rps: u32,
#[arg(long)]
burst: Option<u32>,
},
Off,
}
#[derive(Debug, Subcommand)]
enum TrustedProxyCommand {
Add {
cidr: String,
},
Clear,
}
#[derive(Debug, serde::Serialize)]
#[serde(tag = "result", rename_all = "snake_case")]
pub enum AccessOutput {
Show {
access: boatramp_core::access::AccessConfig,
},
BasicAuthAdded { user: String, site: String },
BasicAuthRemoved { user: String, site: String },
BasicAuthCleared { site: String },
IpAllowed { cidr: String, site: String },
IpDenied { cidr: String, site: String },
IpCleared { site: String },
RateLimitSet { rps: u32, burst: u32, site: String },
RateLimitOff { site: String },
TrustedProxyAdded { cidr: String, site: String },
TrustedProxyCleared { site: String },
WafUserAgentEnabled { site: String },
WafUserAgentDisabled { site: String },
WafAnomalyEnabled { site: String, threshold: u32 },
WafAnomalyDisabled { site: String },
}
impl crate::output::Rendered for AccessOutput {
fn render_human(&self, w: &mut dyn std::io::Write) -> std::io::Result<()> {
match self {
Self::Show { access } => write_access(access, w),
Self::BasicAuthAdded { user, site } => {
writeln!(w, "added basic-auth user {user} to {site}")
}
Self::BasicAuthRemoved { user, site } => {
writeln!(w, "removed basic-auth user {user} from {site}")
}
Self::BasicAuthCleared { site } => {
writeln!(w, "disabled basic auth for {site}")
}
Self::IpAllowed { cidr, site } => writeln!(w, "allow {cidr} on {site}"),
Self::IpDenied { cidr, site } => writeln!(w, "deny {cidr} on {site}"),
Self::IpCleared { site } => writeln!(w, "cleared IP rules for {site}"),
Self::RateLimitSet { rps, burst, site } => {
writeln!(w, "rate limit {rps} req/s (burst {burst}) on {site}")
}
Self::RateLimitOff { site } => {
writeln!(w, "disabled rate limiting for {site}")
}
Self::TrustedProxyAdded { cidr, site } => {
writeln!(w, "trust proxy {cidr} on {site}")
}
Self::TrustedProxyCleared { site } => {
writeln!(w, "cleared trusted proxies for {site}")
}
Self::WafUserAgentEnabled { site } => {
writeln!(w, "waf: user-agent filtering enabled for {site}")
}
Self::WafUserAgentDisabled { site } => {
writeln!(w, "waf: user-agent filtering disabled for {site}")
}
Self::WafAnomalyEnabled { site, threshold } => {
writeln!(
w,
"waf: anomaly scoring enabled for {site} (threshold {threshold})"
)
}
Self::WafAnomalyDisabled { site } => {
writeln!(w, "waf: anomaly scoring disabled for {site}")
}
}
}
}
pub async fn run(args: AccessArgs, config: &ProjectConfig) -> Result<AccessOutput> {
let (server, site) = client::resolve_target(args.server, args.site, config)?;
let cp = client::ControlPlane::new(
server,
client::http_client(client::token(config).as_deref()),
client::resolve_project(config),
);
let mut site_config = cp.fetch_site_config(&site).await?;
let access = &mut site_config.access;
let out = match args.command {
AccessCommand::Show => {
return Ok(AccessOutput::Show {
access: access.clone(),
});
}
AccessCommand::BasicAuth { command } => match command {
BasicAuthCommand::Add {
user,
password,
realm,
} => {
let password = match password {
Some(p) => p,
None => read_stdin_line()?,
};
if password.is_empty() {
return Err(Error::EmptyPassword);
}
let hash = boatramp_core::access::hash_password(&password);
let basic = access.basic_auth.get_or_insert_with(|| BasicAuth {
realm: "Restricted".to_string(),
users: Default::default(),
});
if let Some(realm) = realm {
basic.realm = realm;
}
basic.users.insert(user.clone(), hash);
AccessOutput::BasicAuthAdded {
user,
site: site.clone(),
}
}
BasicAuthCommand::Rm { user } => {
if let Some(basic) = &mut access.basic_auth {
basic.users.remove(&user);
if basic.users.is_empty() {
access.basic_auth = None;
}
}
AccessOutput::BasicAuthRemoved {
user,
site: site.clone(),
}
}
BasicAuthCommand::Clear => {
access.basic_auth = None;
AccessOutput::BasicAuthCleared { site: site.clone() }
}
},
AccessCommand::Ip { command } => match command {
IpCommand::Allow { cidr } => {
push_unique(&mut access.ip.allow, &cidr);
AccessOutput::IpAllowed {
cidr,
site: site.clone(),
}
}
IpCommand::Deny { cidr } => {
push_unique(&mut access.ip.deny, &cidr);
AccessOutput::IpDenied {
cidr,
site: site.clone(),
}
}
IpCommand::Clear => {
access.ip.allow.clear();
access.ip.deny.clear();
AccessOutput::IpCleared { site: site.clone() }
}
},
AccessCommand::RateLimit { command } => match command {
RateLimitCommand::Set { rps, burst } => {
if rps == 0 {
return Err(Error::RpsZero);
}
access.rate_limit = Some(RateLimit {
rps,
burst: burst.unwrap_or(0),
});
AccessOutput::RateLimitSet {
rps,
burst: burst.unwrap_or(rps),
site: site.clone(),
}
}
RateLimitCommand::Off => {
access.rate_limit = None;
AccessOutput::RateLimitOff { site: site.clone() }
}
},
AccessCommand::TrustedProxy { command } => match command {
TrustedProxyCommand::Add { cidr } => {
push_unique(&mut access.trusted_proxies, &cidr);
AccessOutput::TrustedProxyAdded {
cidr,
site: site.clone(),
}
}
TrustedProxyCommand::Clear => {
access.trusted_proxies.clear();
AccessOutput::TrustedProxyCleared { site: site.clone() }
}
},
AccessCommand::Waf { command } => match command {
WafCommand::UserAgent { deny, allow } => {
access.waf.user_agent.enabled = true;
if !deny.is_empty() {
access.waf.user_agent.deny = deny;
}
if !allow.is_empty() {
access.waf.user_agent.allow = allow;
}
AccessOutput::WafUserAgentEnabled { site: site.clone() }
}
WafCommand::UserAgentOff => {
access.waf.user_agent.enabled = false;
AccessOutput::WafUserAgentDisabled { site: site.clone() }
}
WafCommand::Anomaly { threshold } => {
access.waf.anomaly.enabled = true;
if let Some(t) = threshold {
access.waf.anomaly.threshold = t;
}
AccessOutput::WafAnomalyEnabled {
site: site.clone(),
threshold: access.waf.anomaly.threshold,
}
}
WafCommand::AnomalyOff => {
access.waf.anomaly.enabled = false;
AccessOutput::WafAnomalyDisabled { site: site.clone() }
}
},
};
cp.put_site_config(&site, &site_config).await?;
Ok(out)
}
fn push_unique(list: &mut Vec<String>, value: &str) {
if !list.iter().any(|existing| existing == value) {
list.push(value.to_string());
}
}
fn read_stdin_line() -> Result<String> {
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
Ok(buf.trim().to_string())
}
fn write_access(
access: &boatramp_core::access::AccessConfig,
w: &mut dyn std::io::Write,
) -> std::io::Result<()> {
if !access.is_enforced() && access.trusted_proxies.is_empty() {
writeln!(w, "no access control configured")?;
return Ok(());
}
if let Some(basic) = &access.basic_auth {
let users: Vec<&str> = basic.users.keys().map(String::as_str).collect();
writeln!(
w,
"basic-auth realm \"{}\", users: {}",
basic.realm,
users.join(", ")
)?;
}
if !access.ip.allow.is_empty() {
writeln!(w, "ip allow {}", access.ip.allow.join(", "))?;
}
if !access.ip.deny.is_empty() {
writeln!(w, "ip deny {}", access.ip.deny.join(", "))?;
}
if let Some(rl) = &access.rate_limit {
writeln!(
w,
"rate limit {} req/s, burst {}",
rl.rps,
rl.burst_capacity()
)?;
}
if !access.trusted_proxies.is_empty() {
writeln!(w, "trusted px {}", access.trusted_proxies.join(", "))?;
}
Ok(())
}